diff options
123 files changed, 1666 insertions, 907 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5463a020de7..1322843b592 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -75,7 +75,7 @@ stages: # https://docs.gitlab.com/ce/development/writing_documentation.html#testing .except-docs: &except-docs except: - - /^docs\/.*/ + - /(^docs[\/-].*|.*-docs$)/ .rspec-knapsack: &rspec-knapsack stage: test @@ -309,7 +309,7 @@ downtime_check: - master - tags - /^[\d-]+-stable(-ee)?$/ - - /^docs\/*/ + - /(^docs[\/-].*|.*-docs$)/ ee_compat_check: <<: *rake-exec diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 849da633c89..54b85e47358 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -114,6 +114,7 @@ export default class BlobViewer { $(viewer).syntaxHighlight(); this.$fileHolder.trigger('highlight:line'); + gl.utils.handleLocationHash(); this.toggleCopyButtonState(); }) diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 9bcea302da2..082025eabaa 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -59,18 +59,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }, deep: true }, - issue () { - if (this.showSidebar) { - this.$nextTick(() => { - $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0); - $('.right-sidebar').getNiceScroll().resize(); - }); - } - - this.issue = this.detail.issue; - this.list = this.detail.list; - }, - deep: true }, methods: { closeSidebar () { diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index b8be0d8a301..98698143d22 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import Visibility from 'visibilityjs'; -import PipelinesTableComponent from '../../vue_shared/components/pipelines_table'; +import pipelinesTableComponent from '../../vue_shared/components/pipelines_table'; import PipelinesService from '../../pipelines/services/pipelines_service'; import PipelineStore from '../../pipelines/stores/pipelines_store'; import eventHub from '../../pipelines/event_hub'; -import EmptyState from '../../pipelines/components/empty_state.vue'; -import ErrorState from '../../pipelines/components/error_state.vue'; +import emptyState from '../../pipelines/components/empty_state.vue'; +import errorState from '../../pipelines/components/error_state.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; @@ -23,9 +23,9 @@ import Poll from '../../lib/utils/poll'; export default Vue.component('pipelines-table', { components: { - 'pipelines-table-component': PipelinesTableComponent, - 'error-state': ErrorState, - 'empty-state': EmptyState, + pipelinesTableComponent, + errorState, + emptyState, loadingIcon, }, @@ -47,6 +47,7 @@ export default Vue.component('pipelines-table', { hasError: false, isMakingRequest: false, updateGraphDropdown: false, + hasMadeRequest: false, }; }, @@ -55,9 +56,15 @@ export default Vue.component('pipelines-table', { return this.hasError && !this.isLoading; }, + /** + * Empty state is only rendered if after the first request we receive no pipelines. + * + * @return {Boolean} + */ shouldRenderEmptyState() { return !this.state.pipelines.length && !this.isLoading && + this.hasMadeRequest && !this.hasError; }, @@ -94,6 +101,10 @@ export default Vue.component('pipelines-table', { if (!Visibility.hidden()) { this.isLoading = true; this.poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); } Visibility.change(() => { @@ -127,6 +138,8 @@ export default Vue.component('pipelines-table', { successCallback(resp) { const response = resp.json(); + this.hasMadeRequest = true; + // depending of the endpoint the response can either bring a `pipelines` key or not. const pipelines = response.pipelines || response; this.store.storePipelines(pipelines); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index b6b47e2da6f..fdd27534e0e 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -65,4 +65,6 @@ $(() => { 'resolve-count': ResolveCount } }); + + $(window).trigger('resize.nav'); }); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 22d01c484d3..283a4fd4912 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -53,6 +53,7 @@ import BlobViewer from './blob/viewer/index'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import UsersSelect from './users_select'; import RefSelectDropdown from './ref_select_dropdown'; +import GfmAutoComplete from './gfm_auto_complete'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -79,6 +80,8 @@ const ShortcutsBlob = require('./shortcuts_blob'); path = page.split(':'); shortcut_handler = null; + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); + function initBlob() { new LineHighlighter(); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index b3a76fbb43e..988c484ed85 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -5,104 +5,154 @@ require('./preview_markdown'); window.DropzoneInput = (function() { function DropzoneInput(form) { - var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, uploads_path, showError, showSpinner, uploadFile, uploadProgress; + var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile; Dropzone.autoDiscover = false; - alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; - alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; - divHover = "<div class=\"div-dropzone-hover\"></div>"; - divSpinner = "<div class=\"div-dropzone-spinner\"></div>"; - divAlert = "<div class=\"" + alertClass + "\"></div>"; - iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>"; - iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>"; - uploadProgress = $("<div class=\"div-dropzone-progress\"></div>"); - btnAlert = "<button type=\"button\"" + alertAttr + ">×</button>"; - uploads_path = window.uploads_path || null; - max_file_size = gon.max_file_size || 10; - form_textarea = $(form).find(".js-gfm-input"); - form_textarea.wrap("<div class=\"div-dropzone\"></div>"); - form_textarea.on('paste', (function(_this) { + divHover = '<div class="div-dropzone-hover"></div>'; + iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; + $attachButton = form.find('.button-attach-file'); + $attachingFileMessage = form.find('.attaching-file-message'); + $cancelButton = form.find('.button-cancel-uploading-files'); + $retryLink = form.find('.retry-uploading-link'); + $uploadProgress = form.find('.uploading-progress'); + $uploadingErrorContainer = form.find('.uploading-error-container'); + $uploadingErrorMessage = form.find('.uploading-error-message'); + $uploadingProgressContainer = form.find('.uploading-progress-container'); + uploadsPath = window.uploads_path || null; + maxFileSize = gon.max_file_size || 10; + formTextarea = form.find('.js-gfm-input'); + formTextarea.wrap('<div class="div-dropzone"></div>'); + formTextarea.on('paste', (function(_this) { return function(event) { return handlePaste(event); }; })(this)); - $mdArea = $(form_textarea).closest('.md-area'); - $(form).setupMarkdownPreview(); - form_dropzone = $(form).find('.div-dropzone'); - form_dropzone.parent().addClass("div-dropzone-wrapper"); - form_dropzone.append(divHover); - form_dropzone.find(".div-dropzone-hover").append(iconPaperclip); - form_dropzone.append(divSpinner); - form_dropzone.find(".div-dropzone-spinner").append(iconSpinner); - form_dropzone.find(".div-dropzone-spinner").append(uploadProgress); - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); - if (!uploads_path) return; + // Add dropzone area to the form. + $mdArea = formTextarea.closest('.md-area'); + form.setupMarkdownPreview(); + $formDropzone = form.find('.div-dropzone'); + $formDropzone.parent().addClass('div-dropzone-wrapper'); + $formDropzone.append(divHover); + $formDropzone.find('.div-dropzone-hover').append(iconPaperclip); + + if (!uploadsPath) return; - dropzone = form_dropzone.dropzone({ - url: uploads_path, - dictDefaultMessage: "", + dropzone = $formDropzone.dropzone({ + url: uploadsPath, + dictDefaultMessage: '', clickable: true, - paramName: "file", - maxFilesize: max_file_size, + paramName: 'file', + maxFilesize: maxFileSize, uploadMultiple: false, headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, previewContainer: false, processing: function() { - return $(".div-dropzone-alert").alert("close"); + return $('.div-dropzone-alert').alert('close'); }, dragover: function() { $mdArea.addClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0.7); + form.find('.div-dropzone-hover').css('opacity', 0.7); }, dragleave: function() { $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); + form.find('.div-dropzone-hover').css('opacity', 0); }, drop: function() { $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); - form_textarea.focus(); + form.find('.div-dropzone-hover').css('opacity', 0); + formTextarea.focus(); }, success: function(header, response) { const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length; const shouldPad = processingFileCount >= 1; pasteText(response.link.markdown, shouldPad); + // Show 'Attach a file' link only when all files have been uploaded. + if (!processingFileCount) $attachButton.removeClass('hide'); }, - error: function(temp) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed."); - } + error: function(file, errorMessage = 'Attaching the file failed.', xhr) { + // If 'error' event is fired by dropzone, the second parameter is error message. + // If the 'errorMessage' parameter is empty, the default error message is set. + // If the 'error' event is fired by backend (xhr) error response, the third parameter is + // xhr object (xhr.responseText is error message). + // On error we hide the 'Attach' and 'Cancel' buttons + // and show an error. + + // If there's xhr error message, let's show it instead of dropzone's one. + const message = xhr ? xhr.responseText : errorMessage; + + $uploadingErrorContainer.removeClass('hide'); + $uploadingErrorMessage.html(message); + $attachButton.addClass('hide'); + $cancelButton.addClass('hide'); }, totaluploadprogress: function(totalUploadProgress) { - uploadProgress.text(Math.round(totalUploadProgress) + "%"); + updateAttachingMessage(this.files, $attachingFileMessage); + $uploadProgress.text(Math.round(totalUploadProgress) + '%'); + }, + sending: function(file) { + // DOM elements already exist. + // Instead of dynamically generating them, + // we just either hide or show them. + $attachButton.addClass('hide'); + $uploadingErrorContainer.addClass('hide'); + $uploadingProgressContainer.removeClass('hide'); + $cancelButton.removeClass('hide'); }, - sending: function() { - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0.7, - "display": "inherit" - }); + removedfile: function() { + $attachButton.removeClass('hide'); + $cancelButton.addClass('hide'); + $uploadingProgressContainer.addClass('hide'); + $uploadingErrorContainer.addClass('hide'); }, queuecomplete: function() { - uploadProgress.text(""); - $(".dz-preview").remove(); - $(".markdown-area").trigger("input"); - $(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); + $('.dz-preview').remove(); + $('.markdown-area').trigger('input'); + + $uploadingProgressContainer.addClass('hide'); + $cancelButton.addClass('hide'); } }); - child = $(dropzone[0]).children("textarea"); + + child = $(dropzone[0]).children('textarea'); + + // removeAllFiles(true) stops uploading files (if any) + // and remove them from dropzone files queue. + $cancelButton.on('click', (e) => { + const target = e.target.closest('form').querySelector('.div-dropzone'); + + e.preventDefault(); + e.stopPropagation(); + Dropzone.forElement(target).removeAllFiles(true); + }); + + // If 'error' event is fired, we store a failed files, + // clear dropzone files queue, change status of failed files to undefined, + // and add that files to the dropzone files queue again. + // addFile() adds file to dropzone files queue and upload it. + $retryLink.on('click', (e) => { + const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); + const failedFiles = dropzoneInstance.files; + + e.preventDefault(); + + // 'true' parameter of removeAllFiles() cancels uploading of files that are being uploaded at the moment. + dropzoneInstance.removeAllFiles(true); + + failedFiles.map((failedFile, i) => { + const file = failedFile; + + if (file.status === Dropzone.ERROR) { + file.status = undefined; + file.accepted = undefined; + } + + return dropzoneInstance.addFile(file); + }); + }); + handlePaste = function(event) { var filename, image, pasteEvent, text; pasteEvent = event.originalEvent; @@ -110,25 +160,27 @@ window.DropzoneInput = (function() { image = isImage(pasteEvent); if (image) { event.preventDefault(); - filename = getFilename(pasteEvent) || "image.png"; - text = "{{" + filename + "}}"; + filename = getFilename(pasteEvent) || 'image.png'; + text = `{{${filename}}}`; pasteText(text); return uploadFile(image.getAsFile(), filename); } } }; + isImage = function(data) { var i, item; i = 0; while (i < data.clipboardData.items.length) { item = data.clipboardData.items[i]; - if (item.type.indexOf("image") !== -1) { + if (item.type.indexOf('image') !== -1) { return item; } i += 1; } return false; }; + pasteText = function(text, shouldPad) { var afterSelection, beforeSelection, caretEnd, caretStart, textEnd; var formattedText = text; @@ -142,31 +194,33 @@ window.DropzoneInput = (function() { $(child).val(beforeSelection + formattedText + afterSelection); textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); textarea.style.height = `${textarea.scrollHeight}px`; - return form_textarea.trigger("input"); + return formTextarea.trigger('input'); }; + getFilename = function(e) { var value; if (window.clipboardData && window.clipboardData.getData) { - value = window.clipboardData.getData("Text"); + value = window.clipboardData.getData('Text'); } else if (e.clipboardData && e.clipboardData.getData) { - value = e.clipboardData.getData("text/plain"); + value = e.clipboardData.getData('text/plain'); } value = value.split("\r"); return value.first(); }; + uploadFile = function(item, filename) { var formData; formData = new FormData(); - formData.append("file", item, filename); + formData.append('file', item, filename); return $.ajax({ - url: uploads_path, - type: "POST", + url: uploadsPath, + type: 'POST', data: formData, - dataType: "json", + dataType: 'json', processData: false, contentType: false, headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, beforeSend: function() { showSpinner(); @@ -183,44 +237,54 @@ window.DropzoneInput = (function() { } }); }; + + updateAttachingMessage = (files, messageContainer) => { + let attachingMessage; + const filesCount = files.filter(function(file) { + return file.status === 'uploading' || + file.status === 'queued'; + }).length; + + // Dinamycally change uploading files text depending on files number in + // dropzone files queue. + if (filesCount > 1) { + attachingMessage = 'Attaching ' + filesCount + ' files -'; + } else { + attachingMessage = 'Attaching a file -'; + } + + messageContainer.text(attachingMessage); + }; + insertToTextArea = function(filename, url) { return $(child).val(function(index, val) { - return val.replace("{{" + filename + "}}", url); + return val.replace(`{{${filename}}}`, url); }); }; + appendToTextArea = function(url) { return $(child).val(function(index, val) { return val + url + "\n"; }); }; + showSpinner = function(e) { - return form.find(".div-dropzone-spinner").css({ - "opacity": 0.7, - "display": "inherit" - }); + return $uploadingProgressContainer.removeClass('hide'); }; + closeSpinner = function() { - return form.find(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); + return $uploadingProgressContainer.addClass('hide'); }; + showError = function(message) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - return $(".div-dropzone-alert").append(btnAlert + message); - } + $uploadingErrorContainer.removeClass('hide'); + $uploadingErrorMessage.html(message); }; - closeAlertMessage = function() { - return form.find(".div-dropzone-alert").alert("close"); - }; - form.find(".markdown-selector").click(function(e) { + + form.find('.markdown-selector').click(function(e) { e.preventDefault(); $(this).closest('.gfm-form').find('.div-dropzone').click(); - form_textarea.focus(); + formTextarea.focus(); }); } diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 4b030a27900..79c019b3491 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -21,7 +21,6 @@ export default { <a class="btn monitoring-url has-tooltip" data-container="body" - target="_blank" rel="noopener noreferrer nofollow" :href="monitoringUrl" :title="title" diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f1b99023c72..b8a923cf619 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,119 +1,33 @@ -/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */ - import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; import { glEmojiTag } from '~/behaviors/gl_emoji'; import glRegexp from '~/lib/utils/regexp'; -// Creates the variables for setting up GFM auto-completion -window.gl = window.gl || {}; - function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); } -window.gl.GfmAutoComplete = { - dataSources: {}, - defaultLoadingData: ['loading'], - cachedData: {}, - isLoadingData: {}, - atTypeMap: { - ':': 'emojis', - '@': 'members', - '#': 'issues', - '!': 'mergeRequests', - '~': 'labels', - '%': 'milestones', - '/': 'commands' - }, - // Emoji - Emoji: { - templateFunction: function(name) { - return `<li> - ${name} ${glEmojiTag(name)} - </li> - `; - } - }, - // Team Members - Members: { - template: '<li>${avatarTag} ${username} <small>${title}</small></li>' - }, - Labels: { - template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>' - }, - // Issues and MergeRequests - Issues: { - template: '<li><small>${id}</small> ${title}</li>' - }, - // Milestones - Milestones: { - template: '<li>${title}</li>' - }, - Loading: { - template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>' - }, - DefaultOptions: { - sorter: function(query, items, searchKey) { - this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; - if (gl.GfmAutoComplete.isLoading(items)) { - this.setting.highlightFirst = false; - return items; - } - return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey); - }, - filter: function(query, data, searchKey) { - if (gl.GfmAutoComplete.isLoading(data)) { - gl.GfmAutoComplete.fetchData(this.$inputor, this.at); - return data; - } else { - return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); - } - }, - beforeInsert: function(value) { - if (value && !this.setting.skipSpecialCharacterTest) { - var withoutAt = value.substring(1); - if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"'; - } - return value; - }, - matcher: function (flag, subtext) { - // The below is taken from At.js source - // Tweaked to commands to start without a space only if char before is a non-word character - // https://github.com/ichord/At.js - var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar; - atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); - atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); - subtext = subtext.split(/\s+/g).pop(); - flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); - - _a = decodeURI("%C3%80"); - _y = decodeURI("%C3%BF"); - - regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi'); - - match = regexp.exec(subtext); +class GfmAutoComplete { + constructor(dataSources) { + this.dataSources = dataSources || {}; + this.cachedData = {}; + this.isLoadingData = {}; + } - if (match) { - return match[1]; - } else { - return null; - } - } - }, - setup: function(input, enableMap = { + setup(input, enableMap = { emojis: true, members: true, issues: true, milestones: true, mergeRequests: true, - labels: true + labels: true, }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); this.enableMap = enableMap; this.setupLifecycle(); - }, + } + setupLifecycle() { this.input.each((i, input) => { const $input = $(input); @@ -122,9 +36,9 @@ window.gl.GfmAutoComplete = { // Needed for slash commands with suffixes (ex: /label ~) $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); - }, + } - setupAtWho: function($input) { + setupAtWho($input) { if (this.enableMap.emojis) this.setupEmoji($input); if (this.enableMap.members) this.setupMembers($input); if (this.enableMap.issues) this.setupIssues($input); @@ -138,10 +52,11 @@ window.gl.GfmAutoComplete = { alias: 'commands', searchKey: 'search', skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; + data: GfmAutoComplete.defaultLoadingData, + displayTpl(value) { + if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template; + // eslint-disable-next-line no-template-curly-in-string + let tpl = '<li>/${name}'; if (value.aliases.length > 0) { tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; } @@ -153,105 +68,106 @@ window.gl.GfmAutoComplete = { } tpl += '</li>'; return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; + }, + insertTpl(value) { + // eslint-disable-next-line no-template-curly-in-string + let tpl = '/${name} '; + let referencePrefix = null; if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; + referencePrefix = value.params[0][0]; + if (/^[@%~]/.test(referencePrefix)) { + tpl += '<%- referencePrefix %>'; } } - return _.template(tpl)({ reference_prefix: reference_prefix }); + return _.template(tpl)({ referencePrefix }); }, suffix: '', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; + ...this.getDefaultCallbacks(), + beforeSave(commands) { + if (GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, (c) => { + let search = c.name; if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); + search = `${search} ${c.aliases.join(' ')}`; } return { name: c.name, aliases: c.aliases, params: c.params, description: c.description, - search: search + search, }; }); }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); + matcher(flag, subtext) { + const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + const match = regexp.exec(subtext); if (match) { return match[1]; - } else { - return null; } - } - } + return null; + }, + }, }); - return; - }, + } setupEmoji($input) { // Emoji $input.atwho({ at: ':', - displayTpl: function(value) { - return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template; - }.bind(this), + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value && value.name) { + tmpl = GfmAutoComplete.Emoji.templateFunction(value.name); + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: ':${name}:', skipSpecialCharacterTest: true, - data: this.defaultLoadingData, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - - matcher: (flag, subtext) => { + ...this.getDefaultCallbacks(), + matcher(flag, subtext) { const relevantText = subtext.trim().split(/\s/).pop(); const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); const match = regexp.exec(relevantText); return match && match.length ? match[1] : null; - } - } + }, + }, }); - }, + } setupMembers($input) { // Team Members $input.atwho({ at: '@', - displayTpl: function(value) { - return value.username != null ? this.Members.template : this.Loading.template; - }.bind(this), + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.username != null) { + tmpl = GfmAutoComplete.Members.template; + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${username}', searchKey: 'search', alwaysHighlightFirst: true, skipSpecialCharacterTest: true, - data: this.defaultLoadingData, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(members) { - return $.map(members, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(members) { + return $.map(members, (m) => { let title = ''; if (m.username == null) { return m; } title = m.name; if (m.count) { - title += " (" + m.count + ")"; + title += ` (${m.count})`; } const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); @@ -262,173 +178,271 @@ window.gl.GfmAutoComplete = { username: m.username, avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, title: sanitize(title), - search: sanitize(m.username + " " + m.name) + search: sanitize(`${m.username} ${m.name}`), }; }); - } - } + }, + }, }); - }, + } setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Issues.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${id}', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(issues) { - return $.map(issues, function(i) { + ...this.getDefaultCallbacks(), + beforeSave(issues) { + return $.map(issues, (i) => { if (i.title == null) { return i; } return { id: i.iid, title: sanitize(i.title), - search: i.iid + " " + i.title + search: `${i.iid} ${i.title}`, }; }); - } - } + }, + }, }); - }, + } setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', searchKey: 'search', + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', - displayTpl: function(value) { - return value.title != null ? this.Milestones.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Milestones.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - matcher: this.DefaultOptions.matcher, - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - beforeSave: function(milestones) { - return $.map(milestones, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(milestones) { + return $.map(milestones, (m) => { if (m.title == null) { return m; } return { id: m.iid, title: sanitize(m.title), - search: "" + m.title + search: m.title, }; }); - } - } + }, + }, }); - }, + } setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Issues.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${id}', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(merges) { - return $.map(merges, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(merges) { + return $.map(merges, (m) => { if (m.title == null) { return m; } return { id: m.iid, title: sanitize(m.title), - search: m.iid + " " + m.title + search: `${m.iid} ${m.title}`, }; }); - } - } + }, + }, }); - }, + } setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', searchKey: 'search', - data: this.defaultLoadingData, - displayTpl: function(value) { - return this.isLoading(value) ? this.Loading.template : this.Labels.template; - }.bind(this), + data: GfmAutoComplete.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Labels.template; + if (GfmAutoComplete.isLoading(value)) { + tmpl = GfmAutoComplete.Loading.template; + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', callbacks: { - matcher: this.DefaultOptions.matcher, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - sorter: this.DefaultOptions.sorter, - beforeSave: function(merges) { - if (gl.GfmAutoComplete.isLoading(merges)) return merges; - var sanitizeLabelTitle; - sanitizeLabelTitle = function(title) { - if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) { - return "\"" + (sanitize(title)) + "\""; - } else { - return sanitize(title); - } - }; - return $.map(merges, function(m) { - return { - title: sanitize(m.title), - color: m.color, - search: "" + m.title - }; - }); - } - } + ...this.getDefaultCallbacks(), + beforeSave(merges) { + if (GfmAutoComplete.isLoading(merges)) return merges; + return $.map(merges, m => ({ + title: sanitize(m.title), + color: m.color, + search: m.title, + })); + }, + }, }); - }, + } - fetchData: function($input, at) { + getDefaultCallbacks() { + const fetchData = this.fetchData.bind(this); + + return { + sorter(query, items, searchKey) { + this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; + if (GfmAutoComplete.isLoading(items)) { + this.setting.highlightFirst = false; + return items; + } + return $.fn.atwho.default.callbacks.sorter(query, items, searchKey); + }, + filter(query, data, searchKey) { + if (GfmAutoComplete.isLoading(data)) { + fetchData(this.$inputor, this.at); + return data; + } + return $.fn.atwho.default.callbacks.filter(query, data, searchKey); + }, + beforeInsert(value) { + let resultantValue = value; + if (value && !this.setting.skipSpecialCharacterTest) { + const withoutAt = value.substring(1); + if (withoutAt && /[^\w\d]/.test(withoutAt)) { + resultantValue = `${value.charAt()}"${withoutAt}"`; + } + } + return resultantValue; + }, + matcher(flag, subtext) { + // The below is taken from At.js source + // Tweaked to commands to start without a space only if char before is a non-word character + // https://github.com/ichord/At.js + const atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); + const atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); + const targetSubtext = subtext.split(/\s+/g).pop(); + const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + + const accentAChar = decodeURI('%C3%80'); + const accentYChar = decodeURI('%C3%BF'); + + const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi'); + + const match = regexp.exec(targetSubtext); + + if (match) { + return match[1]; + } + return null; + }, + }; + } + + fetchData($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); - } else if (this.atTypeMap[at] === 'emojis') { + } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases))); } else { - $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => { + $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => { this.loadData($input, at, data); }).fail(() => { this.isLoadingData[at] = false; }); } - }, - loadData: function($input, at, data) { + } + loadData($input, at, data) { this.isLoadingData[at] = false; this.cachedData[at] = data; $input.atwho('load', at, data); // This trigger at.js again // otherwise we would be stuck with loading until the user types return $input.trigger('keyup'); - }, - isLoading(data) { - var dataToInspect = data; + } + + static isLoading(data) { + let dataToInspect = data; if (data && data.length > 0) { dataToInspect = data[0]; } - var loadingState = this.defaultLoadingData[0]; + const loadingState = GfmAutoComplete.defaultLoadingData[0]; return dataToInspect && (dataToInspect === loadingState || dataToInspect.name === loadingState); } +} + +GfmAutoComplete.defaultLoadingData = ['loading']; + +GfmAutoComplete.atTypeMap = { + ':': 'emojis', + '@': 'members', + '#': 'issues', + '!': 'mergeRequests', + '~': 'labels', + '%': 'milestones', + '/': 'commands', +}; + +// Emoji +GfmAutoComplete.Emoji = { + templateFunction(name) { + return `<li> + ${name} ${glEmojiTag(name)} + </li> + `; + }, }; +// Team Members +GfmAutoComplete.Members = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li>${avatarTag} ${username} <small>${title}</small></li>', +}; +GfmAutoComplete.Labels = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>', +}; +// Issues and MergeRequests +GfmAutoComplete.Issues = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li><small>${id}</small> ${title}</li>', +}; +// Milestones +GfmAutoComplete.Milestones = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li>${title}</li>', +}; +GfmAutoComplete.Loading = { + template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>', +}; + +export default GfmAutoComplete; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index ff06092e4d6..51822f21e66 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,6 +3,8 @@ /* global DropzoneInput */ /* global autosize */ +import GfmAutoComplete from './gfm_auto_complete'; + window.gl = window.gl || {}; function GLForm(form) { @@ -31,7 +33,7 @@ GLForm.prototype.setupForm = function() { // remove notify commit author checkbox for non-commit notes 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 GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 4520e990e6f..a4d7bf096ef 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -47,7 +47,6 @@ import UsersSelect from './users_select'; Cookies.set('collapsed_gutter', true); } }); - $(".right-sidebar").niceScroll(); } IssuableContext.prototype.initParticipants = function() { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 4310663e0b6..92f6f0d4117 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -6,6 +6,7 @@ /* global Pikaday */ import UsersSelect from './users_select'; +import GfmAutoComplete from './gfm_auto_complete'; (function() { this.IssuableForm = (function() { @@ -20,7 +21,7 @@ import UsersSelect from './users_select'; this.renderWipExplanation = this.renderWipExplanation.bind(this); this.resetAutosave = this.resetAutosave.bind(this); this.handleSubmit = this.handleSubmit.bind(this); - gl.GfmAutoComplete.setup(); + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); new UsersSelect(); new ZenMode(); this.titleField = this.form.find("input[name*='[title]']"); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 30636f6afec..9b9fc31ae93 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -96,7 +96,6 @@ import './dropzone_input'; import './due_date_select'; import './files_comment_button'; import './flash'; -import './gfm_auto_complete'; import './gl_dropdown'; import './gl_field_error'; import './gl_field_errors'; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index aaf6e252abb..91d1afba7b6 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -12,7 +12,6 @@ require('./autosave'); window.autosize = require('vendor/autosize'); window.Dropzone = require('dropzone'); require('./dropzone_input'); -require('./gfm_auto_complete'); require('vendor/jquery.caret'); // required by jquery.atwho require('vendor/jquery.atwho'); require('./task_list'); diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js index 152e75b747e..4d623763ca7 100644 --- a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js +++ b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js @@ -24,9 +24,6 @@ export default { }; }, computed: { - showUnsetWarning() { - return this.cronInterval === ''; - }, intervalIsPreset() { return _.contains(this.cronIntervalPresets, this.cronInterval); }, @@ -63,67 +60,75 @@ export default { }, template: ` <div class="interval-pattern-form-group"> - <input - id="custom" - class="label-light" - type="radio" - :name="inputNameAttribute" - :value="cronInterval" - :checked="isEditable" - @click="toggleCustomInput(true)" - /> + <div class="cron-preset-radio-input"> + <input + id="custom" + class="label-light" + type="radio" + :name="inputNameAttribute" + :value="cronInterval" + :checked="isEditable" + @click="toggleCustomInput(true)" + /> - <label for="custom"> - Custom - </label> + <label for="custom"> + Custom + </label> - <span class="cron-syntax-link-wrap"> - (<a :href="cronSyntaxUrl" target="_blank">Cron syntax</a>) - </span> + <span class="cron-syntax-link-wrap"> + (<a :href="cronSyntaxUrl" target="_blank">Cron syntax</a>) + </span> + </div> - <input - id="every-day" - class="label-light" - type="radio" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyDay" - @click="toggleCustomInput(false)" - /> + <div class="cron-preset-radio-input"> + <input + id="every-day" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyDay" + @click="toggleCustomInput(false)" + /> - <label class="label-light" for="every-day"> - Every day (at 4:00am) - </label> + <label class="label-light" for="every-day"> + Every day (at 4:00am) + </label> + </div> - <input - id="every-week" - class="label-light" - type="radio" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyWeek" - @click="toggleCustomInput(false)" - /> + <div class="cron-preset-radio-input"> + <input + id="every-week" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyWeek" + @click="toggleCustomInput(false)" + /> - <label class="label-light" for="every-week"> - Every week (Sundays at 4:00am) - </label> + <label class="label-light" for="every-week"> + Every week (Sundays at 4:00am) + </label> + </div> - <input - id="every-month" - class="label-light" - type="radio" - v-model="cronInterval" - :name="inputNameAttribute" - :value="cronIntervalPresets.everyMonth" - @click="toggleCustomInput(false)" - /> + <div class="cron-preset-radio-input"> + <input + id="every-month" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyMonth" + @click="toggleCustomInput(false)" + /> - <label class="label-light" for="every-month"> - Every month (on the 1st at 4:00am) - </label> + <label class="label-light" for="every-month"> + Every month (on the 1st at 4:00am) + </label> + </div> - <div class="cron-interval-input-wrapper col-md-6"> + <div class="cron-interval-input-wrapper"> <input id="schedule_cron" class="form-control inline cron-interval-input" @@ -135,9 +140,6 @@ export default { :disabled="!isEditable" /> </div> - <span class="cron-unset-status col-md-3" v-if="showUnsetWarning"> - Schedule not yet set - </span> </div> `, }; diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js index 22e746ad2c3..0c3926d76b5 100644 --- a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js +++ b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js @@ -3,7 +3,7 @@ export default class TargetBranchDropdown { this.$dropdown = $('.js-target-branch-dropdown'); this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); this.$input = $('#schedule_ref'); - this.initialValue = this.$input.val(); + this.initDefaultBranch(); this.initDropdown(); } @@ -29,13 +29,23 @@ export default class TargetBranchDropdown { } setDropdownToggle() { - if (this.initialValue) { - this.$dropdownToggle.text(this.initialValue); + const initialValue = this.$input.val(); + + this.$dropdownToggle.text(initialValue); + } + + initDefaultBranch() { + const initialValue = this.$input.val(); + const defaultBranch = this.$dropdown.data('defaultBranch'); + + if (!initialValue) { + this.$input.val(defaultBranch); } } updateInputValue({ selectedObj, e }) { e.preventDefault(); + this.$input.val(selectedObj.name); gl.pipelineScheduleFieldErrors.updateFormValidityState(); } diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js index c70e0502cf8..95ed9c7dc21 100644 --- a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js +++ b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js @@ -1,12 +1,14 @@ /* eslint-disable class-methods-use-this */ +const defaultTimezone = 'UTC'; + export default class TimezoneDropdown { constructor() { this.$dropdown = $('.js-timezone-dropdown'); this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); this.$input = $('#schedule_cron_timezone'); this.timezoneData = this.$dropdown.data('data'); - this.initialValue = this.$input.val(); + this.initDefaultTimezone(); this.initDropdown(); } @@ -42,12 +44,20 @@ export default class TimezoneDropdown { return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`; } - setDropdownToggle() { - if (this.initialValue) { - this.$dropdownToggle.text(this.initialValue); + initDefaultTimezone() { + const initialValue = this.$input.val(); + + if (!initialValue) { + this.$input.val(defaultTimezone); } } + setDropdownToggle() { + const initialValue = this.$input.val(); + + this.$dropdownToggle.text(initialValue); + } + updateInputValue({ selectedObj, e }) { e.preventDefault(); this.$input.val(selectedObj.identifier); diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 050551e5075..d6952d1ee5f 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -1,12 +1,12 @@ import Visibility from 'visibilityjs'; import PipelinesService from './services/pipelines_service'; import eventHub from './event_hub'; -import PipelinesTableComponent from '../vue_shared/components/pipelines_table'; +import pipelinesTableComponent from '../vue_shared/components/pipelines_table'; import tablePagination from '../vue_shared/components/table_pagination.vue'; -import EmptyState from './components/empty_state.vue'; -import ErrorState from './components/error_state.vue'; -import NavigationTabs from './components/navigation_tabs'; -import NavigationControls from './components/nav_controls'; +import emptyState from './components/empty_state.vue'; +import errorState from './components/error_state.vue'; +import navigationTabs from './components/navigation_tabs'; +import navigationControls from './components/nav_controls'; import loadingIcon from '../vue_shared/components/loading_icon.vue'; import Poll from '../lib/utils/poll'; @@ -20,11 +20,11 @@ export default { components: { tablePagination, - 'pipelines-table-component': PipelinesTableComponent, - 'empty-state': EmptyState, - 'error-state': ErrorState, - 'navigation-tabs': NavigationTabs, - 'navigation-controls': NavigationControls, + pipelinesTableComponent, + emptyState, + errorState, + navigationTabs, + navigationControls, loadingIcon, }, @@ -52,6 +52,7 @@ export default { hasError: false, isMakingRequest: false, updateGraphDropdown: false, + hasMadeRequest: false, }; }, @@ -78,6 +79,7 @@ export default { shouldRenderEmptyState() { return !this.isLoading && !this.hasError && + this.hasMadeRequest && !this.state.pipelines.length && (this.scope === 'all' || this.scope === null); }, @@ -150,6 +152,10 @@ export default { if (!Visibility.hidden()) { this.isLoading = true; poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); } Visibility.change(() => { @@ -202,6 +208,7 @@ export default { this.isLoading = false; this.updateGraphDropdown = true; + this.hasMadeRequest = true; }, errorCallback() { diff --git a/app/assets/javascripts/test.js b/app/assets/javascripts/test.js new file mode 100644 index 00000000000..c4c7918a68f --- /dev/null +++ b/app/assets/javascripts/test.js @@ -0,0 +1 @@ +$.fx.off = true; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js index 517838f92ac..c02e10128e2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -31,7 +31,7 @@ export default { <div class="mr-widget-heading"> <div class="ci-widget"> <template v-if="hasCIError"> - <div class="ci-status-icon ci-status-icon-failed js-ci-error"> + <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error"> <span class="js-icon-link icon-link"> <span v-html="svg" diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index d2f6a227128..e890c2e0634 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -351,7 +351,10 @@ .scrolling-tabs-container { position: relative; - overflow: hidden; + + .merge-request-tabs-container & { + overflow: hidden; + } .nav-links { @include scrolling-links(); @@ -489,7 +492,6 @@ .inner-page-scroll-tabs { position: relative; - overflow: hidden; .fade-right { @include fade(left, $white-light); diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 0184208ab82..ed4e5811b56 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -197,6 +197,15 @@ background: $gray-light; padding: 10px 20px; z-index: 200; + overflow: hidden; + + .issuable-sidebar { + width: calc(100% + 100px); + height: 100%; + overflow-y: scroll; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } &.right-sidebar-expanded { width: $gutter_width; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 16fa03efcb2..e5adca658ba 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -109,6 +109,10 @@ height: 22px; margin-right: 8px; } + + .ci-error { + margin-right: $btn-side-margin; + } } .mr-widget-body, diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 62f654ed343..9db26f99a75 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -277,6 +277,7 @@ .toolbar-text { font-size: 14px; line-height: 16px; + margin-top: 2px; @media (min-width: $screen-md-min) { float: left; @@ -402,3 +403,45 @@ } } } + +.uploading-container { + float: right; + + @media (max-width: $screen-xs-max) { + float: left; + margin-top: 5px; + } +} + +.uploading-error-icon, +.uploading-error-message { + color: $gl-text-red; +} + +.uploading-error-message { + @media (max-width: $screen-xs-max) { + &::after { + content: "\a"; + white-space: pre; + } + } +} + +.uploading-progress { + margin-right: 5px; +} + +.attach-new-file, +.button-attach-file, +.retry-uploading-link { + color: $gl-link-color; + padding: 0; + background: none; + border: 0; + font-size: 14px; + line-height: 16px; +} + +.markdown-selector { + color: $gl-link-color; +} diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss index 0fee54a0d19..ab417948931 100644 --- a/app/assets/stylesheets/pages/pipeline_schedules.scss +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -31,14 +31,6 @@ margin-right: 10px; font-size: 12px; } - - .cron-unset-status { - padding-top: 16px; - margin-left: -16px; - color: $gl-text-color-secondary; - font-size: 12px; - font-weight: 600; - } } .pipeline-schedule-table-row { @@ -69,3 +61,16 @@ color: $gl-text-color; } } + +.cron-preset-radio-input { + display: inline-block; + + @media (max-width: $screen-md-max) { + display: block; + margin: 0 0 5px 5px; + } + + input { + margin-right: 3px; + } +} diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/test.scss new file mode 100644 index 00000000000..7d9f3da79c5 --- /dev/null +++ b/app/assets/stylesheets/test.scss @@ -0,0 +1,17 @@ +* { + -o-transition: none !important; + -moz-transition: none !important; + -ms-transition: none !important; + -webkit-transition: none !important; + transition: none !important; + -o-transform: none !important; + -moz-transform: none !important; + -ms-transform: none !important; + -webkit-transform: none !important; + transform: none !important; + -webkit-animation: none !important; + -moz-animation: none !important; + -o-animation: none !important; + -ms-animation: none !important; + animation: none !important; +} diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb index 4a6630dfd90..1d37e4cb3bd 100644 --- a/app/controllers/concerns/renders_blob.rb +++ b/app/controllers/concerns/renders_blob.rb @@ -14,7 +14,7 @@ module RendersBlob return render_404 unless viewer render json: { - html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_asynchronously: false) + html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false) } end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 97cf4863ddc..e5e64650708 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -278,4 +278,22 @@ module ApplicationHelper def show_user_callout? cookies[:user_callout_dismissed] == 'true' end + + def linkedin_url(user) + name = user.linkedin + if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/} + name + else + "https://www.linkedin.com/in/#{name}" + end + end + + def twitter_url(user) + name = user.twitter + if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/} + name + else + "https://www.twitter.com/#{name}" + end + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index eb37f2e0267..7eb3512378c 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -226,7 +226,7 @@ module BlobHelper def open_raw_blob_button(blob) return if blob.empty? - + if blob.raw_binary? || blob.stored_externally? icon = icon('download') title = 'Download' @@ -242,9 +242,9 @@ module BlobHelper case viewer.render_error when :too_large max_size = - if viewer.absolutely_too_large? - viewer.absolute_max_size - elsif viewer.too_large? + if viewer.can_override_max_size? + viewer.overridable_max_size + else viewer.max_size end "it is larger than #{number_to_human_size(max_size)}" diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 55fa81e95ef..f29faeca22d 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -7,9 +7,10 @@ module IconsHelper # font-awesome-rails gem, but should we ever use a different icon pack in the # future we won't have to change hundreds of method calls. def icon(names, options = {}) - if (options.keys & %w[aria-hidden aria-label]).empty? - # Add `aria-hidden` if there are no aria's set + if (options.keys & %w[aria-hidden aria-label data-hidden]).empty? + # Add 'aria-hidden' and 'data-hidden' if they are not set in options. options['aria-hidden'] = true + options['data-hidden'] = true end options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) @@ -19,6 +20,8 @@ module IconsHelper case names when "standard" names = "key" + when "two-factor" + names = "key" end options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) diff --git a/app/models/blob_viewer/auxiliary.rb b/app/models/blob_viewer/auxiliary.rb index db124397b27..cd6e596ed60 100644 --- a/app/models/blob_viewer/auxiliary.rb +++ b/app/models/blob_viewer/auxiliary.rb @@ -5,8 +5,8 @@ module BlobViewer included do self.loading_partial_name = 'loading_auxiliary' self.type = :auxiliary + self.overridable_max_size = 100.kilobytes self.max_size = 100.kilobytes - self.absolute_max_size = 100.kilobytes end end end diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index 4f38c31714b..c7b8fbfc56a 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -2,11 +2,11 @@ module BlobViewer class Base PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze - class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_type, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size + class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :overridable_max_size, :max_size self.loading_partial_name = 'loading' - delegate :partial_path, :loading_partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class + delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class attr_reader :blob attr_accessor :override_max_size @@ -35,12 +35,8 @@ module BlobViewer type == :auxiliary end - def self.client_side? - client_side - end - - def self.server_side? - !client_side? + def self.load_async? + load_async end def self.binary? @@ -54,21 +50,33 @@ module BlobViewer def self.can_render?(blob, verify_binary: true) return false if verify_binary && binary? != blob.binary? return true if extensions&.include?(blob.extension) - return true if file_type && Gitlab::FileDetector.type_of(blob.path) == file_type + return true if file_types&.include?(Gitlab::FileDetector.type_of(blob.path)) false end - def too_large? - blob.raw_size > max_size + def load_async? + self.class.load_async? && render_error.nil? end - def absolutely_too_large? - blob.raw_size > absolute_max_size + def exceeds_overridable_max_size? + overridable_max_size && blob.raw_size > overridable_max_size + end + + def exceeds_max_size? + max_size && blob.raw_size > max_size end def can_override_max_size? - too_large? && !absolutely_too_large? + exceeds_overridable_max_size? && !exceeds_max_size? + end + + def too_large? + if override_max_size + exceeds_max_size? + else + exceeds_overridable_max_size? + end end # This method is used on the server side to check whether we can attempt to @@ -83,29 +91,13 @@ module BlobViewer # binary from `blob_raw_url` and does its own format validation and error # rendering, especially for potentially large binary formats. def render_error - return @render_error if defined?(@render_error) - - @render_error = - if server_side_but_stored_externally? - # Files that are not stored in the repository, like LFS files and - # build artifacts, can only be rendered using a client-side viewer, - # since we do not want to read large amounts of data into memory on the - # server side. Client-side viewers use JS and can fetch the file from - # `blob_raw_url` using AJAX. - :server_side_but_stored_externally - elsif override_max_size ? absolutely_too_large? : too_large? - :too_large - end + if too_large? + :too_large + end end def prepare! # To be overridden by subclasses end - - private - - def server_side_but_stored_externally? - server_side? && blob.stored_externally? - end end end diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb index 42ec68f864b..cc68236f92b 100644 --- a/app/models/blob_viewer/client_side.rb +++ b/app/models/blob_viewer/client_side.rb @@ -3,9 +3,9 @@ module BlobViewer extend ActiveSupport::Concern included do - self.client_side = true - self.max_size = 10.megabytes - self.absolute_max_size = 50.megabytes + self.load_async = false + self.overridable_max_size = 10.megabytes + self.max_size = 50.megabytes end end end diff --git a/app/models/blob_viewer/download.rb b/app/models/blob_viewer/download.rb index adc06587f69..074e7204814 100644 --- a/app/models/blob_viewer/download.rb +++ b/app/models/blob_viewer/download.rb @@ -1,17 +1,9 @@ module BlobViewer class Download < Base include Simple - # We treat the Download viewer as if it renders the content client-side, - # so that it doesn't attempt to load the entire blob contents and is - # rendered synchronously instead of loaded asynchronously. - include ClientSide + include Static self.partial_name = 'download' self.binary = true - - # We can always render the Download viewer, even if the blob is in LFS or too large. - def render_error - nil - end end end diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb index 81afab2f49b..7267c3965d3 100644 --- a/app/models/blob_viewer/gitlab_ci_yml.rb +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -5,7 +5,7 @@ module BlobViewer self.partial_name = 'gitlab_ci_yml' self.loading_partial_name = 'gitlab_ci_yml_loading' - self.file_type = :gitlab_ci + self.file_types = %i(gitlab_ci) self.binary = false def validation_message diff --git a/app/models/blob_viewer/license.rb b/app/models/blob_viewer/license.rb index 3ad49570c88..6751ea15a5d 100644 --- a/app/models/blob_viewer/license.rb +++ b/app/models/blob_viewer/license.rb @@ -1,13 +1,10 @@ module BlobViewer class License < Base - # We treat the License viewer as if it renders the content client-side, - # so that it doesn't attempt to load the entire blob contents and is - # rendered synchronously instead of loaded asynchronously. - include ClientSide include Auxiliary + include Static self.partial_name = 'license' - self.file_type = :license + self.file_types = %i(license) self.binary = false def license diff --git a/app/models/blob_viewer/route_map.rb b/app/models/blob_viewer/route_map.rb index 1ca730c1ea0..153b4eeb2c9 100644 --- a/app/models/blob_viewer/route_map.rb +++ b/app/models/blob_viewer/route_map.rb @@ -5,7 +5,7 @@ module BlobViewer self.partial_name = 'route_map' self.loading_partial_name = 'route_map_loading' - self.file_type = :route_map + self.file_types = %i(route_map) self.binary = false def validation_message diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb index e8c5c17b824..87884dcd6bf 100644 --- a/app/models/blob_viewer/server_side.rb +++ b/app/models/blob_viewer/server_side.rb @@ -3,9 +3,9 @@ module BlobViewer extend ActiveSupport::Concern included do - self.client_side = false - self.max_size = 2.megabytes - self.absolute_max_size = 5.megabytes + self.load_async = true + self.overridable_max_size = 2.megabytes + self.max_size = 5.megabytes end def prepare! @@ -13,5 +13,18 @@ module BlobViewer blob.load_all_data!(blob.project.repository) end end + + def render_error + if blob.stored_externally? + # Files that are not stored in the repository, like LFS files and + # build artifacts, can only be rendered using a client-side viewer, + # since we do not want to read large amounts of data into memory on the + # server side. Client-side viewers use JS and can fetch the file from + # `blob_raw_url` using AJAX. + return :server_side_but_stored_externally + end + + super + end end end diff --git a/app/models/blob_viewer/static.rb b/app/models/blob_viewer/static.rb new file mode 100644 index 00000000000..c9e257e5388 --- /dev/null +++ b/app/models/blob_viewer/static.rb @@ -0,0 +1,14 @@ +module BlobViewer + module Static + extend ActiveSupport::Concern + + included do + self.load_async = false + end + + # We can always render a static viewer, even if the blob is too large. + def render_error + nil + end + end +end diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb index e27b2c2b493..eddca50b4d4 100644 --- a/app/models/blob_viewer/text.rb +++ b/app/models/blob_viewer/text.rb @@ -5,7 +5,7 @@ module BlobViewer self.partial_name = 'text' self.binary = false - self.max_size = 1.megabyte - self.absolute_max_size = 10.megabytes + self.overridable_max_size = 1.megabyte + self.max_size = 10.megabytes end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3c4a4d93349..760ec8e5919 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -300,8 +300,8 @@ module Ci def execute_hooks return unless project build_data = Gitlab::DataBuilder::Build.build(self) - project.execute_hooks(build_data.dup, :build_hooks) - project.execute_services(build_data.dup, :build_hooks) + project.execute_hooks(build_data.dup, :job_hooks) + project.execute_services(build_data.dup, :job_hooks) PagesService.new(build_data).execute project.running_or_pending_build_count(force: true) end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index c631e7a7df5..ee6165fd32d 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -5,7 +5,7 @@ class ProjectHook < WebHook scope :confidential_issue_hooks, -> { where(confidential_issues_events: true) } scope :note_hooks, -> { where(note_events: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true) } - scope :build_hooks, -> { where(build_events: true) } + scope :job_hooks, -> { where(job_events: true) } scope :pipeline_hooks, -> { where(pipeline_events: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true) } end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 7cf03aabd6f..a165fdc312f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -8,7 +8,7 @@ class WebHook < ActiveRecord::Base default_value_for :note_events, false default_value_for :merge_requests_events, false default_value_for :tag_push_events, false - default_value_for :build_events, false + default_value_for :job_events, false default_value_for :pipeline_events, false default_value_for :repository_update_events, false default_value_for :enable_ssl_verification, true diff --git a/app/models/service.rb b/app/models/service.rb index c71a7d169ec..8916f88076e 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -12,7 +12,7 @@ class Service < ActiveRecord::Base default_value_for :merge_requests_events, true default_value_for :tag_push_events, true default_value_for :note_events, true - default_value_for :build_events, true + default_value_for :job_events, true default_value_for :pipeline_events, true default_value_for :wiki_page_events, true @@ -40,7 +40,7 @@ class Service < ActiveRecord::Base scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } - scope :build_hooks, -> { where(build_events: true, active: true) } + scope :job_hooks, -> { where(job_events: true, active: true) } scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index 7912cac65d3..9e84e2a8f62 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -29,7 +29,7 @@ module Members issue_ids = IssuesFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). execute.pluck(:id) - IssueAssignee.destroy_all(issue_id: issue_ids, user_id: member.user_id) + IssueAssignee.delete_all(issue_id: issue_ids, user_id: member.user_id) MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). execute. @@ -37,10 +37,15 @@ module Members else project = member.source - IssueAssignee.destroy_all( - user_id: member.user_id, - issue_id: project.issues.opened.assigned_to(member.user).select(:id) - ) + # SELECT 1 FROM issues WHERE issues.id = issue_assignees.issue_id AND issues.project_id = X + issues = Issue.unscoped.select(1). + where('issues.id = issue_assignees.issue_id'). + where(project_id: project.id) + + # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) + IssueAssignee.unscoped. + where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues). + delete_all project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) end diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 6a208d76a38..4deccf4aa93 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -16,24 +16,15 @@ = icon('spinner') Reset health check access token %p.light - Health information can be retrieved as plain text, JSON, or XML using: + Health information can be retrieved from the following endpoints. More information is available + = link_to 'here', help_page_path('user/admin_area/monitoring/health_check') %ul %li - %code= health_check_url(token: current_application_settings.health_check_access_token) + %code= readiness_url(token: current_application_settings.health_check_access_token) %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) + %code= liveness_url(token: current_application_settings.health_check_access_token) %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) - - %p.light - You can also ask for the status of specific services: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) + %code= metrics_url(token: current_application_settings.health_check_access_token) %hr .panel.panel-default diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index 3338b677bf5..e92b8bc39f4 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -27,7 +27,7 @@ = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' .monospace= hook.url %div - - %w(repository_update_events push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger| + - %w(repository_update_events push_events tag_push_events issues_events note_events merge_requests_events job_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray= trigger.titleize %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 5e189e6dc54..eb0e6701627 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -6,10 +6,10 @@ = devise_error_messages! = f.hidden_field :reset_password_token .form-group - = f.label 'New password', for: :password + = f.label 'New password', for: "user_password" = f.password_field :password, class: "form-control top", required: true, title: 'This field is required' .form-group - = f.label 'Confirm new password', for: :password_confirmation + = f.label 'Confirm new password', for: "user_password_confirmation" = f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', required: true .clearfix = f.submit "Change your password", class: "btn btn-primary" diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 21c751a23f8..4095f30c369 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,6 +1,6 @@ = form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| .form-group - = f.label "Username or email", for: :login + = f.label "Username or email", for: "user_login" = f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required." .form-group = f.label :password diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index afcc2b6e4f3..9e354987401 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -27,6 +27,7 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" + = stylesheet_link_tag "test", media: "all" if Rails.env.test? = Gon::Base.render_data @@ -34,6 +35,7 @@ = webpack_bundle_tag "common" = webpack_bundle_tag "main" = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled + = webpack_bundle_tag "test" if Rails.env.test? - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 769f6fb0151..6caaba240bb 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -3,6 +3,7 @@ - if project :javascript + gl.GfmAutoComplete = gl.GfmAutoComplete || {}; gl.GfmAutoComplete.dataSources = { members: "#{members_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}", issues: "#{issues_namespace_project_autocomplete_sources_path(project.namespace, project)}", @@ -11,5 +12,3 @@ milestones: "#{milestones_namespace_project_autocomplete_sources_path(project.namespace, project)}", commands: "#{commands_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" }; - - gl.GfmAutoComplete.setup(); diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 7e011ac3e75..03688e9ff21 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -2,8 +2,8 @@ %html{ lang: I18n.locale, class: "#{page_class}" } = render "layouts/head" %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } + = render "layouts/init_auto_complete" if @gfm_form = render "layouts/header/default", title: header_title = render 'layouts/page', sidebar: sidebar, nav: nav = yield :scripts_body - = render "layouts/init_auto_complete" if @gfm_form diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index e06301bda14..ae1e1361f0f 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -48,6 +48,6 @@ %span Preferences = nav_link(path: 'profiles#audit_log') do - = link_to audit_log_profile_path, title: 'Audit Log' do + = link_to audit_log_profile_path, title: 'Authentication log' do %span - Audit Log + Authentication log diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index 879fc170f92..d0ad90ac6cc 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -9,7 +9,6 @@ Signed in with = event.details[:with] authentication - %span.pull-right - #{time_ago_in_words event.created_at} ago + %span.pull-right= time_ago_with_tooltip(event.created_at) = paginate events, theme: "gitlab" diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 9fe86e6b291..a24b7fd101d 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,4 +1,4 @@ -- page_title "Audit Log" +- page_title "Authentication log" = render 'profiles/head' .row.prepend-top-default diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index 3d9c3a59980..4252f27d007 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -1,10 +1,10 @@ - hidden = local_assigns.fetch(:hidden, false) - render_error = viewer.render_error -- load_asynchronously = local_assigns.fetch(:load_asynchronously, viewer.server_side?) && render_error.nil? +- load_async = local_assigns.fetch(:load_async, viewer.load_async?) -- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_asynchronously +- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async .blob-viewer{ data: { type: viewer.type, url: viewer_url }, class: ('hidden' if hidden) } - - if load_asynchronously + - if load_async = render viewer.loading_partial_path, viewer: viewer - elsif render_error = render 'projects/blob/render_error', viewer: viewer diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index b7515e1d91f..75120409bb3 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -21,7 +21,8 @@ #js-vue-mr-widget.mr-widget - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('vue_merge_request_widget') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'vue_merge_request_widget' .content-block.content-block-small.emoji-list-container = render 'award_emoji/awards_block', awardable: @merge_request, inline: true diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index d6f4f1a206c..bbed10039af 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -5,29 +5,29 @@ = form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f| = form_errors(@schedule) .form-group - .col-md-6 + .col-md-9 = f.label :description, 'Description', class: 'label-light' = f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: 'Provide a short description for this pipeline' .form-group - .col-md-12 + .col-md-9 = f.label :cron, 'Interval Pattern', class: 'label-light' #interval-pattern-input{ data: { initial_interval: @schedule.cron } } .form-group - .col-md-6 + .col-md-9 = f.label :cron_timezone, 'Cron Timezone', class: 'label-light' = dropdown_tag("Select a timezone", options: { toggle_class: 'btn js-timezone-dropdown', title: "Select a timezone", filter: true, placeholder: "Filter", data: { data: timezone_data } } ) = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true .form-group - .col-md-6 + .col-md-9 = f.label :ref, 'Target Branch', class: 'label-light' - = dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown git-revision-dropdown-toggle', dropdown_class: 'git-revision-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names } } ) + = dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown git-revision-dropdown-toggle', dropdown_class: 'git-revision-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } ) = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true .form-group - .col-md-6 + .col-md-9 = f.label :active, 'Activated', class: 'label-light' %div = f.check_box :active, required: false, value: @schedule.active? - active + Active .footer-block.row-content-block = f.submit 'Save pipeline schedule', class: 'btn btn-create', tabindex: 3 = link_to 'Cancel', pipeline_schedules_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml index 8dc276a3bec..a6640592dba 100644 --- a/app/views/projects/settings/integrations/_project_hook.html.haml +++ b/app/views/projects/settings/integrations/_project_hook.html.haml @@ -3,7 +3,7 @@ .col-md-8.col-lg-7 %strong.light-header= hook.url %div - - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger| + - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events job_events pipeline_events wiki_page_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray.deploy-project-label= trigger.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 81d97eabe65..7ce6130de60 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -9,6 +9,27 @@ - else is supported - %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } - = icon('file-image-o', class: 'toolbar-button-icon') - Attach a file + + %span.uploading-container + %span.uploading-progress-container.hide + = icon('file-image-o', class: 'toolbar-button-icon') + %span.attaching-file-message + -# Populated by app/assets/javascripts/dropzone_input.js + %span.uploading-progress 0% + %span.uploading-spinner + = icon('spinner spin', class: 'toolbar-button-icon') + + %span.uploading-error-container.hide + %span.uploading-error-icon + = icon('file-image-o', class: 'toolbar-button-icon') + %span.uploading-error-message + -# Populated by app/assets/javascripts/dropzone_input.js + %button.retry-uploading-link{ type: 'button' } Try again + or + %button.attach-new-file.markdown-selector{ type: 'button' } attach a new file + + %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' } + = icon('file-image-o', class: 'toolbar-button-icon') + Attach a file + + %button.btn.btn-default.btn-xs.hide.button-cancel-uploading-files{ type: 'button' } Cancel diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 37c3e61912c..1f0e7629fb4 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -54,10 +54,10 @@ %p.light This URL will be triggered when a merge request is created/updated/merged %li - = form.check_box :build_events, class: 'pull-left' + = form.check_box :job_events, class: 'pull-left' .prepend-left-20 - = form.label :build_events, class: 'list-label' do - %strong Jobs events + = form.label :job_events, class: 'list-label' do + %strong Job events %p.light This URL will be triggered when the job status changes %li diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 03e5dd97405..8e8b84e0408 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -56,11 +56,11 @@ = icon('skype') - unless @user.linkedin.blank? .profile-link-holder.middle-dot-divider - = link_to "https://www.linkedin.com/in/#{@user.linkedin}", title: "LinkedIn" do + = link_to linkedin_url(@user), title: "LinkedIn" do = icon('linkedin-square') - unless @user.twitter.blank? .profile-link-holder.middle-dot-divider - = link_to "https://twitter.com/#{@user.twitter}", title: "Twitter" do + = link_to twitter_url(@user), title: "Twitter" do = icon('twitter-square') - unless @user.website_url.blank? .profile-link-holder.middle-dot-divider diff --git a/changelogs/unreleased/30827-changes-to-audit-log.yml b/changelogs/unreleased/30827-changes-to-audit-log.yml new file mode 100644 index 00000000000..32db3bf8e95 --- /dev/null +++ b/changelogs/unreleased/30827-changes-to-audit-log.yml @@ -0,0 +1,4 @@ +--- +title: Renamed users 'Audit Log'' to 'Authentication Log' +merge_request: 11400 +author: diff --git a/changelogs/unreleased/31998-pipelines-empty-state.yml b/changelogs/unreleased/31998-pipelines-empty-state.yml new file mode 100644 index 00000000000..78ae222255e --- /dev/null +++ b/changelogs/unreleased/31998-pipelines-empty-state.yml @@ -0,0 +1,4 @@ +--- +title: Fix Pipelines table empty state - only render empty state if we receive 0 pipelines +merge_request: +author: diff --git a/changelogs/unreleased/32340-correct-jobs-api-documentation b/changelogs/unreleased/32340-correct-jobs-api-documentation new file mode 100644 index 00000000000..4ada62356eb --- /dev/null +++ b/changelogs/unreleased/32340-correct-jobs-api-documentation @@ -0,0 +1,4 @@ +--- +title: "Correction to documention for manual steps on the Jobs API" +merge_request: 11411 +author: Zac Sturgess
\ No newline at end of file diff --git a/changelogs/unreleased/32395-duplicate-string-in-https-docs-gitlab-com-ce-administration-environment_variables-html.yml b/changelogs/unreleased/32395-duplicate-string-in-https-docs-gitlab-com-ce-administration-environment_variables-html.yml new file mode 100644 index 00000000000..d2be3d6cc4b --- /dev/null +++ b/changelogs/unreleased/32395-duplicate-string-in-https-docs-gitlab-com-ce-administration-environment_variables-html.yml @@ -0,0 +1,4 @@ +--- +title: Removes duplicate environment variable in documentation +merge_request: +author: diff --git a/changelogs/unreleased/add_ability_to_cancel_attaching_file_and_redesign_attaching_files_ui.yml b/changelogs/unreleased/add_ability_to_cancel_attaching_file_and_redesign_attaching_files_ui.yml new file mode 100644 index 00000000000..fcf4efa2846 --- /dev/null +++ b/changelogs/unreleased/add_ability_to_cancel_attaching_file_and_redesign_attaching_files_ui.yml @@ -0,0 +1,4 @@ +--- +title: Add an ability to cancel attaching file and redesign attaching files UI +merge_request: 9431 +author: blackst0ne diff --git a/changelogs/unreleased/bvl-rename-build-events-to-job-events.yml b/changelogs/unreleased/bvl-rename-build-events-to-job-events.yml new file mode 100644 index 00000000000..2ce01a71361 --- /dev/null +++ b/changelogs/unreleased/bvl-rename-build-events-to-job-events.yml @@ -0,0 +1,4 @@ +--- +title: Rename build_events to job_events +merge_request: 11287 +author: diff --git a/changelogs/unreleased/environments-button-open-same-tab.yml b/changelogs/unreleased/environments-button-open-same-tab.yml new file mode 100644 index 00000000000..60b0d389e7f --- /dev/null +++ b/changelogs/unreleased/environments-button-open-same-tab.yml @@ -0,0 +1,5 @@ +--- +title: Removed the target=_blank from the monitoring component to prevent opening + a new tab +merge_request: +author: diff --git a/changelogs/unreleased/update-admin-health-page.yml b/changelogs/unreleased/update-admin-health-page.yml new file mode 100644 index 00000000000..51aa6682b49 --- /dev/null +++ b/changelogs/unreleased/update-admin-health-page.yml @@ -0,0 +1,5 @@ +--- +title: Added application readiness endpoints to the monitoring health check admin + view +merge_request: +author: diff --git a/config/application.rb b/config/application.rb index bf3fb7a18c1..95ba6774916 100644 --- a/config/application.rb +++ b/config/application.rb @@ -106,6 +106,7 @@ module Gitlab config.assets.precompile << "xterm/xterm.css" config.assets.precompile << "lib/ace.js" config.assets.precompile << "vendor/assets/fonts/*" + config.assets.precompile << "test.css" # Version of your assets, change this if you want to expire all your assets config.assets.version = '1.0' diff --git a/config/webpack.config.js b/config/webpack.config.js index 5d5a42512b1..0781017c89f 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -63,6 +63,7 @@ var config = { users: './users/users_bundle.js', raven: './raven/index.js', vue_merge_request_widget: './vue_merge_request_widget/index.js', + test: './test.js', }, output: { @@ -151,6 +152,7 @@ var config = { 'schedule_form', 'schedules_index', 'sidebar', + 'vue_merge_request_widget', ], minChunks: function(module, count) { return module.resource && (/vue_shared/).test(module.resource); diff --git a/db/migrate/20170320171632_create_issue_assignees_table.rb b/db/migrate/20170320171632_create_issue_assignees_table.rb deleted file mode 100644 index 23b8da37b6d..00000000000 --- a/db/migrate/20170320171632_create_issue_assignees_table.rb +++ /dev/null @@ -1,40 +0,0 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - -class CreateIssueAssigneesTable < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - INDEX_NAME = 'index_issue_assignees_on_issue_id_and_user_id' - - # Set this constant to true if this migration requires downtime. - DOWNTIME = false - - # When a migration requires downtime you **must** uncomment the following - # constant and define a short and easy to understand explanation as to why the - # migration requires downtime. - # DOWNTIME_REASON = '' - - # When using the methods "add_concurrent_index" or "add_column_with_default" - # you must disable the use of transactions as these methods can not run in an - # existing transaction. When using "add_concurrent_index" make sure that this - # method is the _only_ method called in the migration, any other changes - # should go in a separate migration. This ensures that upon failure _only_ the - # index creation fails and can be retried or reverted easily. - # - # To disable transactions uncomment the following line and remove these - # comments: - # disable_ddl_transaction! - - def up - create_table :issue_assignees do |t| - t.references :user, foreign_key: { on_delete: :cascade }, index: true, null: false - t.references :issue, foreign_key: { on_delete: :cascade }, null: false - end - - add_index :issue_assignees, [:issue_id, :user_id], unique: true, name: INDEX_NAME - end - - def down - drop_table :issue_assignees - end -end diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb index ba8edbd7d32..23e7500a32d 100644 --- a/db/migrate/20170320173259_migrate_assignees.rb +++ b/db/migrate/20170320173259_migrate_assignees.rb @@ -37,16 +37,8 @@ class MigrateAssignees < ActiveRecord::Migration users.project("true").where(users[:id].eq(table[:assignee_id])).exists.not )) end - - execute <<-EOF - INSERT INTO issue_assignees(issue_id, user_id) - SELECT id, assignee_id FROM issues WHERE assignee_id IS NOT NULL - EOF end def down - execute <<-EOF - DELETE FROM issue_assignees - EOF end end diff --git a/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb b/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb new file mode 100644 index 00000000000..a2320a911b7 --- /dev/null +++ b/db/migrate/20170511082759_rename_web_hooks_build_events_to_job_events.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RenameWebHooksBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + rename_column_concurrently :web_hooks, :build_events, :job_events + end + + def down + cleanup_concurrent_column_rename :web_hooks, :job_events, :build_events + end +end diff --git a/db/migrate/20170511083824_rename_services_build_events_to_job_events.rb b/db/migrate/20170511083824_rename_services_build_events_to_job_events.rb new file mode 100644 index 00000000000..303d47078e7 --- /dev/null +++ b/db/migrate/20170511083824_rename_services_build_events_to_job_events.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RenameServicesBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + rename_column_concurrently :services, :build_events, :job_events + end + + def down + cleanup_concurrent_column_rename :services, :job_events, :build_events + end +end diff --git a/db/migrate/20170516153305_migrate_assignee_to_separate_table.rb b/db/migrate/20170516153305_migrate_assignee_to_separate_table.rb new file mode 100644 index 00000000000..f269ca7fc34 --- /dev/null +++ b/db/migrate/20170516153305_migrate_assignee_to_separate_table.rb @@ -0,0 +1,83 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class MigrateAssigneeToSeparateTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index", "remove_concurrent_index" or + # "add_column_with_default" you must disable the use of transactions + # as these methods can not run in an existing transaction. + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def up + drop_table(:issue_assignees) if table_exists?(:issue_assignees) + + if Gitlab::Database.mysql? + execute <<-EOF + CREATE TABLE issue_assignees AS + SELECT assignee_id AS user_id, id AS issue_id FROM issues WHERE assignee_id IS NOT NULL + EOF + else + ActiveRecord::Base.transaction do + execute('LOCK TABLE issues IN EXCLUSIVE MODE') + + execute <<-EOF + CREATE TABLE issue_assignees AS + SELECT assignee_id AS user_id, id AS issue_id FROM issues WHERE assignee_id IS NOT NULL + EOF + + execute <<-EOF + CREATE OR REPLACE FUNCTION replicate_assignee_id() + RETURNS trigger AS + $BODY$ + BEGIN + if OLD.assignee_id IS NOT NULL THEN + DELETE FROM issue_assignees WHERE issue_id = OLD.id; + END IF; + + if NEW.assignee_id IS NOT NULL THEN + INSERT INTO issue_assignees (user_id, issue_id) VALUES (NEW.assignee_id, NEW.id); + END IF; + + RETURN NEW; + END; + $BODY$ + LANGUAGE 'plpgsql' + VOLATILE; + + CREATE TRIGGER replicate_assignee_id + BEFORE INSERT OR UPDATE OF assignee_id + ON issues + FOR EACH ROW EXECUTE PROCEDURE replicate_assignee_id(); + EOF + end + end + end + + def down + drop_table(:issue_assignees) if table_exists?(:issue_assignees) + + if Gitlab::Database.postgresql? + execute <<-EOF + DROP TRIGGER IF EXISTS replicate_assignee_id ON issues; + DROP FUNCTION IF EXISTS replicate_assignee_id(); + EOF + end + end +end diff --git a/db/migrate/20170516183131_add_indices_to_issue_assignees.rb b/db/migrate/20170516183131_add_indices_to_issue_assignees.rb new file mode 100644 index 00000000000..a1f064c6848 --- /dev/null +++ b/db/migrate/20170516183131_add_indices_to_issue_assignees.rb @@ -0,0 +1,41 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndicesToIssueAssignees < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index", "remove_concurrent_index" or + # "add_column_with_default" you must disable the use of transactions + # as these methods can not run in an existing transaction. + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + add_concurrent_index :issue_assignees, [:issue_id, :user_id], unique: true, name: 'index_issue_assignees_on_issue_id_and_user_id' + add_concurrent_index :issue_assignees, :user_id, name: 'index_issue_assignees_on_user_id' + add_concurrent_foreign_key :issue_assignees, :users, column: :user_id, on_delete: :cascade + add_concurrent_foreign_key :issue_assignees, :issues, column: :issue_id, on_delete: :cascade + end + + def down + remove_foreign_key :issue_assignees, column: :user_id + remove_foreign_key :issue_assignees, column: :issue_id + remove_concurrent_index :issue_assignees, [:issue_id, :user_id] if index_exists?(:issue_assignees, [:issue_id, :user_id]) + remove_concurrent_index :issue_assignees, :user_id if index_exists?(:issue_assignees, :user_id) + end +end diff --git a/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb b/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb new file mode 100644 index 00000000000..281be90163a --- /dev/null +++ b/db/post_migrate/20170511100900_cleanup_rename_web_hooks_build_events_to_job_events.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CleanupRenameWebHooksBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :web_hooks, :build_events, :job_events + end + + def down + rename_column_concurrently :web_hooks, :job_events, :build_events + end +end diff --git a/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb b/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb new file mode 100644 index 00000000000..5d26df5688f --- /dev/null +++ b/db/post_migrate/20170511101000_cleanup_rename_services_build_events_to_job_events.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CleanupRenameServicesBuildEventsToJobEvents < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + cleanup_concurrent_column_rename :services, :build_events, :job_events + end + + def down + rename_column_concurrently :services, :job_events, :build_events + end +end diff --git a/db/post_migrate/20170516165238_cleanup_trigger_for_issues.rb b/db/post_migrate/20170516165238_cleanup_trigger_for_issues.rb new file mode 100644 index 00000000000..378fe5603c3 --- /dev/null +++ b/db/post_migrate/20170516165238_cleanup_trigger_for_issues.rb @@ -0,0 +1,39 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class CleanupTriggerForIssues < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index", "remove_concurrent_index" or + # "add_column_with_default" you must disable the use of transactions + # as these methods can not run in an existing transaction. + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + if Gitlab::Database.postgresql? + execute <<-EOF + DROP TRIGGER IF EXISTS replicate_assignee_id ON issues; + DROP FUNCTION IF EXISTS replicate_assignee_id(); + EOF + end + end + + def down + end +end diff --git a/db/post_migrate/20170516181025_add_constraints_to_issue_assignees_table.rb b/db/post_migrate/20170516181025_add_constraints_to_issue_assignees_table.rb new file mode 100644 index 00000000000..2aab1f4d14f --- /dev/null +++ b/db/post_migrate/20170516181025_add_constraints_to_issue_assignees_table.rb @@ -0,0 +1,37 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddConstraintsToIssueAssigneesTable < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index", "remove_concurrent_index" or + # "add_column_with_default" you must disable the use of transactions + # as these methods can not run in an existing transaction. + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def up + change_column :issue_assignees, :issue_id, :integer, null: false + change_column :issue_assignees, :user_id, :integer, null: false + end + + def down + change_column :issue_assignees, :issue_id, :integer + change_column :issue_assignees, :user_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index fd0fb2d7220..294e0b531eb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170510101043) do +ActiveRecord::Schema.define(version: 20170516183131) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -459,7 +459,7 @@ ActiveRecord::Schema.define(version: 20170510101043) do add_index "identities", ["user_id"], name: "index_identities_on_user_id", using: :btree - create_table "issue_assignees", force: :cascade do |t| + create_table "issue_assignees", id: false, force: :cascade do |t| t.integer "user_id", null: false t.integer "issue_id", null: false end @@ -1125,13 +1125,13 @@ ActiveRecord::Schema.define(version: 20170510101043) do t.boolean "merge_requests_events", default: true t.boolean "tag_push_events", default: true t.boolean "note_events", default: true, null: false - t.boolean "build_events", default: false, null: false t.string "category", default: "common", null: false t.boolean "default", default: false t.boolean "wiki_page_events", default: true t.boolean "pipeline_events", default: false, null: false t.boolean "confidential_issues_events", default: true, null: false t.boolean "commit_events", default: true, null: false + t.boolean "job_events", default: false, null: false end add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree @@ -1401,11 +1401,11 @@ ActiveRecord::Schema.define(version: 20170510101043) do t.boolean "tag_push_events", default: false t.boolean "note_events", default: false, null: false t.boolean "enable_ssl_verification", default: true - t.boolean "build_events", default: false, null: false t.boolean "wiki_page_events", default: false, null: false t.string "token" t.boolean "pipeline_events", default: false, null: false t.boolean "confidential_issues_events", default: false, null: false + t.boolean "job_events", default: false, null: false t.boolean "repository_update_events", default: false, null: false end @@ -1423,8 +1423,8 @@ ActiveRecord::Schema.define(version: 20170510101043) do add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade add_foreign_key "ci_variables", "projects", name: "fk_ada5eb64b3", on_delete: :cascade add_foreign_key "container_repositories", "projects" - add_foreign_key "issue_assignees", "issues", on_delete: :cascade - add_foreign_key "issue_assignees", "users", on_delete: :cascade + add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade + add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade add_foreign_key "label_priorities", "projects", on_delete: :cascade diff --git a/doc/administration/environment_variables.md b/doc/administration/environment_variables.md index 76029b30dd8..b6676026d06 100644 --- a/doc/administration/environment_variables.md +++ b/doc/administration/environment_variables.md @@ -20,7 +20,6 @@ Variable | Type | Description `GITLAB_EMAIL_FROM` | string | The e-mail address used in the "From" field in e-mails sent by GitLab `GITLAB_EMAIL_DISPLAY_NAME` | string | The name used in the "From" field in e-mails sent by GitLab `GITLAB_EMAIL_REPLY_TO` | string | The e-mail address used in the "Reply-To" field in e-mails sent by GitLab -`GITLAB_EMAIL_REPLY_TO` | string | The e-mail address used in the "Reply-To" field in e-mails sent by GitLab `GITLAB_EMAIL_SUBJECT_SUFFIX` | string | The e-mail subject suffix used in e-mails sent by GitLab `GITLAB_UNICORN_MEMORY_MIN` | integer | The minimum memory threshold (in bytes) for the Unicorn worker killer `GITLAB_UNICORN_MEMORY_MAX` | integer | The maximum memory threshold (in bytes) for the Unicorn worker killer diff --git a/doc/api/jobs.md b/doc/api/jobs.md index 404da3dc603..297115e94ac 100644 --- a/doc/api/jobs.md +++ b/doc/api/jobs.md @@ -11,7 +11,7 @@ GET /projects/:id/jobs | Attribute | Type | Required | Description | |-----------|---------|----------|---------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`; showing all jobs if none provided | +| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`, `manual`; showing all jobs if none provided | ``` curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/1/jobs?scope[]=pending&scope[]=running' @@ -125,7 +125,7 @@ GET /projects/:id/pipelines/:pipeline_id/jobs |---------------|--------------------------------|----------|----------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `pipeline_id` | integer | yes | The ID of a pipeline | -| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`; showing all jobs if none provided | +| `scope` | string **or** array of strings | no | The scope of jobs to show, one or array of: `created`, `pending`, `running`, `failed`, `success`, `canceled`, `skipped`, `manual`; showing all jobs if none provided | ``` curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/1/pipelines/6/jobs?scope[]=pending&scope[]=running' diff --git a/doc/api/services.md b/doc/api/services.md index 0f42c256099..f77d15c2ea1 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -516,7 +516,7 @@ Example response: "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "pipeline_events": true, "properties": { "token": "9koXpg98eAheJpvBs5tK" diff --git a/doc/development/README.md b/doc/development/README.md index 63db332b557..934c6849ff9 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -50,6 +50,10 @@ - [Post Deployment Migrations](post_deployment_migrations.md) - [Foreign Keys & Associations](foreign_keys.md) +## i18n + +- [Internationalization for GitLab](i18n_guide.md) + ## Compliance - [Licensing](licensing.md) for ensuring license compliance diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md index a08694fb66a..64bcb4a0257 100644 --- a/doc/development/fe_guide/index.md +++ b/doc/development/fe_guide/index.md @@ -45,15 +45,11 @@ should be `new-feature`. branch from `new-feature`, let's call it `new-feature-step-2` and repeat the process done before. ```shell -* master -|\ -| * new-feature -| |\ -| | * new-feature-step-1 -| |\ -| | * new-feature-step-2 -| |\ -| | * new-feature-step-3 +master +└─ new-feature + ├─ new-feature-step-1 + ├─ new-feature-step-2 + └─ new-feature-step-3 ``` **Tips** diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md new file mode 100644 index 00000000000..44eca68aaca --- /dev/null +++ b/doc/development/i18n_guide.md @@ -0,0 +1,239 @@ +# Internationalization for GitLab + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10669) in GitLab 9.2. + +For working with internationalization (i18n) we use +[GNU gettext](https://www.gnu.org/software/gettext/) given it's the most used +tool for this task and we have a lot of applications that will help us to work +with it. + +## Tools + +We use a couple of gems: + +1. [`gettext_i18n_rails`](https://github.com/grosser/gettext_i18n_rails): this + gem allow us to translate content from models, views and controllers. Also + it gives us access to the following raketasks: + - `rake gettext:find`: Parses almost all the files from the + Rails application looking for content that has been marked for + translation. Finally, it updates the PO files with the new content that + it has found. + - `rake gettext:pack`: Processes the PO files and generates the + MO files that are binary and are finally used by the application. + +1. [`gettext_i18n_rails_js`](https://github.com/webhippie/gettext_i18n_rails_js): + this gem is useful to make the translations available in JavaScript. It + provides the following raketask: + - `rake gettext:po_to_json`: Reads the contents from the PO files and + generates JSON files containing all the available translations. + +1. PO editor: there are multiple applications that can help us to work with PO + files, a good option is [Poedit](https://poedit.net/download) which is + available for macOS, GNU/Linux and Windows. + +## Preparing a page for translation + +We basically have 4 types of files: + +1. Ruby files: basically Models and Controllers. +1. HAML files: these are the view files. +1. ERB files: used for email templates. +1. JavaScript files: we mostly need to work with VUE JS templates. + +### Ruby files + +If there is a method or variable that works with a raw string, for instance: + +```ruby +def hello + "Hello world!" +end +``` + +Or: + +```ruby +hello = "Hello world!" +``` + +You can easily mark that content for translation with: + +```ruby +def hello + _("Hello world!") +end +``` + +Or: + +```ruby +hello = _("Hello world!") +``` + +### HAML files + +Given the following content in HAML: + +```haml +%h1 Hello world! +``` + +You can mark that content for translation with: + +```haml +%h1= _("Hello world!") +``` + +### ERB files + +Given the following content in ERB: + +```erb +<h1>Hello world!</h1> +``` + +You can mark that content for translation with: + +```erb +<h1><%= _("Hello world!") %></h1> +``` + +### JavaScript files + +In JavaScript we added the `__()` (double underscore parenthesis) function +for translations. + +### Updating the PO files with the new content + +Now that the new content is marked for translation, we need to update the PO +files with the following command: + +```sh +bundle exec rake gettext:find +``` + +This command will update the `locale/**/gitlab.edit.po` file with the +new content that the parser has found. + +New translations will be added with their default content and will be marked +fuzzy. To use the translation, look for the `#, fuzzy` mention in `gitlab.edit.po` +and remove it. + +Translations that aren't used in the source code anymore will be marked with +`~#`; these can be removed to keep our translation files clutter-free. + +## Working with special content + +### Interpolation + +- In Ruby/HAML: + + ```ruby + _("Hello %{name}") % { name: 'Joe' } + ``` + +- In JavaScript: Not supported at this moment. + +### Plurals + +- In Ruby/HAML: + + ```ruby + n_('Apple', 'Apples', 3) => 'Apples' + ``` + + Using interpolation: + ```ruby + n_("There is a mouse.", "There are %d mice.", size) % size + ``` + +- In JavaScript: + + ```js + n__('Apple', 'Apples', 3) => 'Apples' + ``` + + Using interpolation: + + ```js + n__('Last day', 'Last %d days', 30) => 'Last 30 days' + ``` + +### Namespaces + +Sometimes you need to add some context to the text that you want to translate +(if the word occurs in a sentence and/or the word is ambiguous). + +- In Ruby/HAML: + + ```ruby + s_('OpenedNDaysAgo|Opened') + ``` + + In case the translation is not found it will return `Opened`. + +- In JavaScript: + + ```js + s__('OpenedNDaysAgo|Opened') + ``` + +### Just marking content for parsing + +Sometimes there are some dynamic translations that can't be found by the +parser when running `bundle exec rake gettext:find`. For these scenarios you can +use the [`_N` method](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#unfound-translations-with-rake-gettextfind). + +There is also and alternative method to [translate messages from validation errors](https://github.com/grosser/gettext_i18n_rails/blob/c09e38d481e0899ca7d3fc01786834fa8e7aab97/Readme.md#option-a). + +## Adding a new language + +Let's suppose you want to add translations for a new language, let's say French. + +1. The first step is to register the new language in `lib/gitlab/i18n.rb`: + + ```ruby + ... + AVAILABLE_LANGUAGES = { + ..., + 'fr' => 'Français' + }.freeze + ... + ``` + +1. Next, you need to add the language: + + ```sh + bundle exec rake gettext:add_language[fr] + ``` + + If you want to add a new language for a specific region, the command is similar, + you just need to separate the region with an underscore (`_`). For example: + + ```sh + bundle exec rake gettext:add_language[en_gb] + ``` + +1. Now that the language is added, a new directory has been created under the + path: `locale/fr/`. You can now start using your PO editor to edit the PO file + located in: `locale/fr/gitlab.edit.po`. + +1. After you're done updating the translations, you need to process the PO files + in order to generate the binary MO files and finally update the JSON files + containing the translations: + + ```sh + bundle exec rake gettext:pack + bundle exec rake gettext:po_to_json + ``` + +1. In order to see the translated content we need to change our preferred language + which can be found under the user's **Settings** (`/profile`). + +1. After checking that the changes are ok, you can proceed to commit the new files. + For example: + + ```sh + git add locale/fr/ app/assets/javascripts/locale/fr/ + git commit -m "Add French translations for Cycle Analytics page" + ``` diff --git a/doc/development/ux_guide/basics.md b/doc/development/ux_guide/basics.md index 259b214bd59..a436e9b1948 100644 --- a/doc/development/ux_guide/basics.md +++ b/doc/development/ux_guide/basics.md @@ -22,7 +22,7 @@ GitLab's main typeface used throughout the UI is **Source Sans Pro**. We support ### Monospace typeface -This is the typeface used for code blocks. GitLab uses the OS default font. +This is the typeface used for code blocks and references to commits, branches, and tags (`.commit-sha` or `.ref-name`). GitLab uses the OS default font. - **Menlo** (Mac) - **Consolas** (Windows) - **Liberation Mono** (Linux) diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index 657a826d7ee..eac9ec2a470 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -78,14 +78,21 @@ Currently GitLab docs use Redcarpet as [markdown](../user/markdown.md) engine, b We try to treat documentation as code, thus have implemented some testing. Currently, the following tests are in place: -1. `docs:check:links`: Check that all internal (relative) links work correctly -1. `docs:check:apilint`: Check that the API docs follow some conventions +1. `docs lint`: Check that all internal (relative) links work correctly and + that all cURL examples in API docs use the full switches. If your contribution contains **only** documentation changes, you can speed up -the CI process by prepending to the name of your branch: `docs/`. For example, -a valid name would be `docs/update-api-issues` and it will run only the docs -tests. If the name is `docs-update-api-issues`, the whole test suite will run -(including docs). +the CI process by following some branch naming conventions. You have three +choices: + +| Branch name | Valid example | +| ----------- | ------------- | +| Starting with `docs/` | `docs/update-api-issues` | +| Starting with `docs-` | `docs-update-api-issues` | +| Ending in `-docs` | `123-update-api-issues-docs` | + +If your branch name matches any of the above, it will run only the docs +tests. If it doesn't, the whole test suite will run (including docs). --- diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index ce5da07c61a..a4726673fc4 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -71,8 +71,10 @@ structure. - You need to be an Owner of a group in order to be able to create a subgroup. For more information check the [permissions table][permissions]. - For a list of words that are not allowed to be used as group names see the - [`namespace_validator.rb` file][reserved] under the `RESERVED` and - `WILDCARD_ROUTES` lists. + [`dynamic_path_validator.rb` file][reserved] under the `TOP_LEVEL_ROUTES`, `WILDCARD_ROUTES` and `GROUP_ROUTES` lists: + - `TOP_LEVEL_ROUTES`: are names that are reserved as usernames or top level groups + - `WILDCARD_ROUTES`: are names that are reserved for child groups or projects. + - `GROUP_ROUTES`: are names that are reserved for all groups or projects. To create a subgroup: @@ -161,4 +163,4 @@ Here's a list of what you can't do with subgroups: [ce-2772]: https://gitlab.com/gitlab-org/gitlab-ce/issues/2772 [permissions]: ../../permissions.md#group -[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/validators/namespace_validator.rb +[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/validators/dynamic_path_validator.rb diff --git a/features/profile/active_tab.feature b/features/profile/active_tab.feature index 788b7895d72..21d7d6c3800 100644 --- a/features/profile/active_tab.feature +++ b/features/profile/active_tab.feature @@ -23,7 +23,7 @@ Feature: Profile Active Tab Then the active main tab should be Preferences And no other main tabs should be active - Scenario: On Profile Audit Log - Given I visit Audit Log page - Then the active main tab should be Audit Log + Scenario: On Profile Authentication log + Given I visit Authentication log page + Then the active main tab should be Authentication log And no other main tabs should be active diff --git a/features/profile/profile.feature b/features/profile/profile.feature index 70f47c97173..3263d3e212b 100644 --- a/features/profile/profile.feature +++ b/features/profile/profile.feature @@ -63,7 +63,7 @@ Feature: Profile Given I logout And I sign in via the UI And I have activity - When I visit Audit Log page + When I visit Authentication log page Then I should see my activity Scenario: I visit my user page diff --git a/features/steps/profile/active_tab.rb b/features/steps/profile/active_tab.rb index 4724a326277..069d4e6a23d 100644 --- a/features/steps/profile/active_tab.rb +++ b/features/steps/profile/active_tab.rb @@ -19,7 +19,7 @@ class Spinach::Features::ProfileActiveTab < Spinach::FeatureSteps ensure_active_main_tab('Preferences') end - step 'the active main tab should be Audit Log' do - ensure_active_main_tab('Audit Log') + step 'the active main tab should be Authentication log' do + ensure_active_main_tab('Authentication log') end end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 46b3cb79af2..bef3eac4d26 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -152,7 +152,7 @@ module SharedPaths visit profile_preferences_path end - step 'I visit Audit Log page' do + step 'I visit Authentication log page' do visit audit_log_profile_path end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 3fc2b453eb6..01cc8e8e1ca 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -60,7 +60,7 @@ module API class ProjectHook < Hook expose :project_id, :issues_events, :merge_requests_events expose :note_events, :pipeline_events, :wiki_page_events - expose :build_events, as: :job_events + expose :job_events end class BasicProjectDetails < Grape::Entity @@ -470,7 +470,7 @@ module API expose :id, :title, :created_at, :updated_at, :active expose :push_events, :issues_events, :merge_requests_events expose :tag_push_events, :note_events, :pipeline_events - expose :build_events, as: :job_events + expose :job_events # Expose serialized properties expose :properties do |service, options| field_names = service.fields. diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 87dfd1573a4..7a345289617 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -54,7 +54,6 @@ module API end post ":id/hooks" do hook_params = declared_params(include_missing: false) - hook_params[:build_events] = hook_params.delete(:job_events) { false } hook = user_project.hooks.new(hook_params) @@ -78,7 +77,6 @@ module API hook = user_project.hooks.find(params.delete(:hook_id)) update_params = declared_params(include_missing: false) - update_params[:build_events] = update_params.delete(:job_events) if update_params[:job_events] if hook.update_attributes(update_params) present hook, with: Entities::ProjectHook diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 56a9b019f1b..332f233bf5e 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -238,7 +238,8 @@ module API class ProjectService < Grape::Entity expose :id, :title, :created_at, :updated_at, :active expose :push_events, :issues_events, :merge_requests_events - expose :tag_push_events, :note_events, :build_events, :pipeline_events + expose :tag_push_events, :note_events, :pipeline_events + expose :job_events, as: :build_events # Expose serialized properties expose :properties do |service, options| field_names = service.fields. @@ -250,7 +251,8 @@ module API class ProjectHook < ::API::Entities::Hook expose :project_id, :issues_events, :merge_requests_events - expose :note_events, :build_events, :pipeline_events, :wiki_page_events + expose :note_events, :pipeline_events, :wiki_page_events + expose :job_events, as: :build_events end class Issue < ::API::Entities::Issue diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb index 496ee0bdcb0..06438d2df41 100644 --- a/lib/gitlab/ee_compat_check.rb +++ b/lib/gitlab/ee_compat_check.rb @@ -309,6 +309,17 @@ module Gitlab U lib/gitlab/ee_compat_check.rb Resolve them, stage the changes and commit them. + + If the patch couldn't be applied cleanly, use the following command: + + # In the EE repo + $ git apply --reject path/to/#{ce_branch}.patch + + This option makes git apply the parts of the patch that are applicable, + and leave the rejected hunks in corresponding `.rej` files. + You can then resolve the conflicts highlighted in `.rej` by + manually applying the correct diff from the `.rej` file to the file with conflicts. + When finished, you can delete the `.rej` files and commit your changes. ⚠️ Don't forget to push your branch to gitlab-ee: diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index d380c5021ee..a2c01ec4432 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -471,19 +471,19 @@ module Gitlab # Returns a RefName for a given SHA def ref_name_for_sha(ref_path, sha) - # NOTE: This feature is intentionally disabled until - # https://gitlab.com/gitlab-org/gitaly/issues/180 is resolved - # gitaly_migrate(:find_ref_name) do |is_enabled| - # if is_enabled - # gitaly_ref_client.find_ref_name(sha, ref_path) - # else - 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).first.split.last - # end - # end + raise ArgumentError, "sha can't be empty" unless sha.present? + + gitaly_migrate(:find_ref_name) do |is_enabled| + if is_enabled + gitaly_ref_client.find_ref_name(sha, ref_path) + else + 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).first.split.last + end + end end # Returns commits collection diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index bf04e1fa50b..53c43e28df8 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -28,7 +28,7 @@ module Gitlab def find_ref_name(commit_id, ref_prefix) request = Gitaly::FindRefNameRequest.new( - repository: @repository, + repository: @gitaly_repo, commit_id: commit_id, prefix: ref_prefix ) diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 0210e871a63..cd754ea235f 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -14,7 +14,7 @@ FactoryGirl.define do issues_events true confidential_issues_events true note_events true - build_events true + job_events true pipeline_events true wiki_page_events true end diff --git a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb index 5820784f8e7..c102722d6db 100644 --- a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb +++ b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb @@ -34,11 +34,13 @@ feature 'Merge immediately', :feature, :js do page.within '.mr-widget-body' do find('.dropdown-toggle').click - click_link 'Merge immediately' + Sidekiq::Testing.fake! do + click_link 'Merge immediately' - expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress') + expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress') - wait_for_ajax + wait_for_vue_resource + end end end end diff --git a/spec/features/projects/gfm_autocomplete_load_spec.rb b/spec/features/projects/gfm_autocomplete_load_spec.rb index dd9622f16a0..67bc9142356 100644 --- a/spec/features/projects/gfm_autocomplete_load_spec.rb +++ b/spec/features/projects/gfm_autocomplete_load_spec.rb @@ -10,7 +10,7 @@ describe 'GFM autocomplete loading', feature: true, js: true do end it 'does not load on project#show' do - expect(evaluate_script('gl.GfmAutoComplete.dataSources')).to eq({}) + expect(evaluate_script('gl.GfmAutoComplete')).to eq(nil) end it 'loads on new issue page' do diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index fe9f94db574..03a30bfb996 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -70,6 +70,11 @@ feature 'Pipeline Schedules', :feature do describe 'POST /projects/pipeline_schedules/new', js: true do let(:visit_page) { visit_new_pipeline_schedule } + it 'sets defaults for timezone and target branch' do + expect(page).to have_button('master') + expect(page).to have_button('UTC') + end + it 'it creates a new scheduled pipeline' do fill_in_schedule_form save_pipeline_schedule @@ -118,12 +123,12 @@ feature 'Pipeline Schedules', :feature do end def select_timezone - click_button 'Select a timezone' + find('.js-timezone-dropdown').click click_link 'American Samoa' end def select_target_branch - click_button 'Select target branch' + find('.js-target-branch-dropdown').click click_link 'master' end diff --git a/spec/features/projects/settings/integration_settings_spec.rb b/spec/features/projects/settings/integration_settings_spec.rb index 7909234556e..d3232f0cc16 100644 --- a/spec/features/projects/settings/integration_settings_spec.rb +++ b/spec/features/projects/settings/integration_settings_spec.rb @@ -52,6 +52,7 @@ feature 'Integration settings', feature: true do fill_in 'hook_url', with: url check 'Tag push events' check 'Enable SSL verification' + check 'Job events' click_button 'Add webhook' @@ -59,6 +60,7 @@ feature 'Integration settings', feature: true do expect(page).to have_content('SSL Verification: enabled') expect(page).to have_content('Push Events') expect(page).to have_content('Tag Push Events') + expect(page).to have_content('Job events') end scenario 'edit existing webhook' do diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb index 0c160dd74b4..8f03024ea06 100644 --- a/spec/features/uploads/user_uploads_file_to_note_spec.rb +++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb @@ -5,18 +5,78 @@ feature 'User uploads file to note', feature: true do let(:user) { create(:user) } let(:project) { create(:empty_project, creator: user, namespace: user.namespace) } + let(:issue) { create(:issue, project: project, author: user) } - scenario 'they see the attached file', js: true do - issue = create(:issue, project: project, author: user) - + before do login_as(user) visit namespace_project_issue_path(project.namespace, project, issue) + end + + context 'before uploading' do + it 'shows "Attach a file" button', js: true do + expect(page).to have_button('Attach a file') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end + end + + context 'uploading is in progress' do + it 'shows "Cancel" button on uploading', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + expect(page).to have_button('Cancel') + end + + it 'cancels uploading on clicking to "Cancel" button', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + click_button 'Cancel' + + expect(page).to have_button('Attach a file') + expect(page).not_to have_button('Cancel') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end + + it 'shows "Attaching a file" message on uploading 1 file', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching a file -') + end + + it 'shows "Attaching 2 files" message on uploading 2 file', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4'), + Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + expect(page).to have_selector('.attaching-file-message', visible: true, text: 'Attaching 2 files -') + end + + it 'shows error message, "retry" and "attach a new file" link a if file is too big', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'video_sample.mp4')], 0.01) + + error_text = 'File is too big (0.06MiB). Max filesize: 0.01MiB.' + + expect(page).to have_selector('.uploading-error-message', visible: true, text: error_text) + expect(page).to have_selector('.retry-uploading-link', visible: true, text: 'Try again') + expect(page).to have_selector('.attach-new-file', visible: true, text: 'attach a new file') + expect(page).not_to have_button('Attach a file') + end + end + + context 'uploading is complete' do + it 'shows "Attach a file" button on uploading complete', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')]) + wait_for_ajax + + expect(page).to have_button('Attach a file') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end - dropzone_file(Rails.root.join('spec', 'fixtures', 'dk.png')) - click_button 'Comment' - wait_for_ajax + scenario 'they see the attached file', js: true do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')]) + click_button 'Comment' + wait_for_ajax - expect(find('a.no-attachment-icon img[alt="dk"]')['src']) - .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$}) + expect(find('a.no-attachment-icon img[alt="dk"]')['src']) + .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$}) + end end end diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 1b4393e6167..41b5df12522 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -116,10 +116,11 @@ describe BlobHelper do let(:viewer_class) do Class.new(BlobViewer::Base) do - self.max_size = 1.megabyte - self.absolute_max_size = 5.megabytes + include BlobViewer::ServerSide + + self.overridable_max_size = 1.megabyte + self.max_size = 5.megabytes self.type = :rich - self.client_side = false end end diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js index 5dfa4008fbd..d0f15c902b5 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js +++ b/spec/javascripts/gfm_auto_complete_spec.js @@ -1,13 +1,15 @@ /* eslint no-param-reassign: "off" */ -require('~/gfm_auto_complete'); +import GfmAutoComplete from '~/gfm_auto_complete'; + require('vendor/jquery.caret'); require('vendor/jquery.atwho'); -const global = window.gl || (window.gl = {}); -const GfmAutoComplete = global.GfmAutoComplete; - describe('GfmAutoComplete', function () { + const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ + fetchData: () => {}, + }); + describe('DefaultOptions.sorter', function () { describe('assets loading', function () { beforeEach(function () { @@ -16,7 +18,7 @@ describe('GfmAutoComplete', function () { this.atwhoInstance = { setting: {} }; this.items = []; - this.sorterValue = GfmAutoComplete.DefaultOptions.sorter + this.sorterValue = gfmAutoCompleteCallbacks.sorter .call(this.atwhoInstance, '', this.items); }); @@ -38,7 +40,7 @@ describe('GfmAutoComplete', function () { it('should enable highlightFirst if alwaysHighlightFirst is set', function () { const atwhoInstance = { setting: { alwaysHighlightFirst: true } }; - GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance); + gfmAutoCompleteCallbacks.sorter.call(atwhoInstance); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); @@ -46,7 +48,7 @@ describe('GfmAutoComplete', function () { it('should enable highlightFirst if a query is present', function () { const atwhoInstance = { setting: {} }; - GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, 'query'); + gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, 'query'); expect(atwhoInstance.setting.highlightFirst).toBe(true); }); @@ -58,7 +60,7 @@ describe('GfmAutoComplete', function () { const items = []; const searchKey = 'searchKey'; - GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, query, items, searchKey); + gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, query, items, searchKey); expect($.fn.atwho.default.callbacks.sorter).toHaveBeenCalledWith(query, items, searchKey); }); @@ -67,7 +69,7 @@ describe('GfmAutoComplete', function () { describe('DefaultOptions.matcher', function () { const defaultMatcher = (context, flag, subtext) => ( - GfmAutoComplete.DefaultOptions.matcher.call(context, flag, subtext) + gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext) ); const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%']; diff --git a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js b/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js index 08fa6ca9057..845b371d90c 100644 --- a/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js +++ b/spec/javascripts/pipeline_schedules/interval_pattern_input_spec.js @@ -36,20 +36,6 @@ describe('Interval Pattern Input Component', function () { expect(this.intervalPatternComponent.initialCronInterval).toBe(this.initialCronInterval); }); - it('sets showUnsetWarning to false', function (done) { - Vue.nextTick(() => { - expect(this.intervalPatternComponent.showUnsetWarning).toBe(false); - done(); - }); - }); - - it('does not render showUnsetWarning', function (done) { - Vue.nextTick(() => { - expect(this.intervalPatternComponent.$el.outerHTML).not.toContain('Schedule not yet set'); - done(); - }); - }); - it('sets isEditable to true', function (done) { Vue.nextTick(() => { expect(this.intervalPatternComponent.isEditable).toBe(true); @@ -72,20 +58,6 @@ describe('Interval Pattern Input Component', function () { expect(this.intervalPatternComponent).toBeDefined(); }); - it('sets showUnsetWarning to false', function (done) { - Vue.nextTick(() => { - expect(this.intervalPatternComponent.showUnsetWarning).toBe(false); - done(); - }); - }); - - it('does not render showUnsetWarning', function (done) { - Vue.nextTick(() => { - expect(this.intervalPatternComponent.$el.outerHTML).not.toContain('Schedule not yet set'); - done(); - }); - }); - it('sets isEditable to false', function (done) { Vue.nextTick(() => { expect(this.intervalPatternComponent.isEditable).toBe(false); @@ -113,20 +85,6 @@ describe('Interval Pattern Input Component', function () { expect(this.intervalPatternComponent.initialCronInterval).toBe(defaultInitialCronInterval); }); - it('sets showUnsetWarning to true', function (done) { - Vue.nextTick(() => { - expect(this.intervalPatternComponent.showUnsetWarning).toBe(true); - done(); - }); - }); - - it('renders showUnsetWarning to true', function (done) { - Vue.nextTick(() => { - expect(this.intervalPatternComponent.$el.outerHTML).toContain('Schedule not yet set'); - done(); - }); - }); - it('sets isEditable to true', function (done) { Vue.nextTick(() => { expect(this.intervalPatternComponent.isEditable).toBe(true); diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index fea186fd4f4..53d492b8f74 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -26,6 +26,7 @@ describe Gitlab::Git::Repository, seed_helper: true do context 'with gitaly enabled' do before { stub_gitaly } + after { Gitlab::GitalyClient.clear_stubs! } it 'gets the branch name from GitalyClient' do expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:default_branch_name) @@ -120,6 +121,7 @@ describe Gitlab::Git::Repository, seed_helper: true do context 'with gitaly enabled' do before { stub_gitaly } + after { Gitlab::GitalyClient.clear_stubs! } it 'gets the branch names from GitalyClient' do expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:branch_names) @@ -157,6 +159,7 @@ describe Gitlab::Git::Repository, seed_helper: true do context 'with gitaly enabled' do before { stub_gitaly } + after { Gitlab::GitalyClient.clear_stubs! } it 'gets the tag names from GitalyClient' do expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:tag_names) @@ -1046,6 +1049,28 @@ describe Gitlab::Git::Repository, seed_helper: true do end end + describe '#ref_name_for_sha' do + let(:ref_path) { 'refs/heads' } + let(:sha) { repository.find_branch('master').dereferenced_target.id } + let(:ref_name) { 'refs/heads/master' } + + it 'returns the ref name for the given sha' do + expect(repository.ref_name_for_sha(ref_path, sha)).to eq(ref_name) + end + + it "returns an empty name if the ref doesn't exist" do + expect(repository.ref_name_for_sha(ref_path, "000000")).to eq("") + end + + it "raise an exception if the ref is empty" do + expect { repository.ref_name_for_sha(ref_path, "") }.to raise_error(ArgumentError) + end + + it "raise an exception if the ref is nil" do + expect { repository.ref_name_for_sha(ref_path, nil) }.to raise_error(ArgumentError) + end + end + describe '#find_commits' do it 'should return a return a collection of commits' do commits = repository.find_commits diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index fdbb6a0556d..e3599d6fe59 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -6997,7 +6997,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "TeamcityService", "category": "ci", "default": false, @@ -7041,7 +7041,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "RedmineService", "category": "issue_tracker", "default": false, @@ -7063,7 +7063,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "PushoverService", "category": "common", "default": false, @@ -7085,7 +7085,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "PivotalTrackerService", "category": "common", "default": false, @@ -7108,7 +7108,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "JiraService", "category": "issue_tracker", "default": false, @@ -7130,7 +7130,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "IrkerService", "category": "common", "default": false, @@ -7174,7 +7174,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "GemnasiumService", "category": "common", "default": false, @@ -7196,7 +7196,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "FlowdockService", "category": "common", "default": false, @@ -7218,7 +7218,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "ExternalWikiService", "category": "common", "default": false, @@ -7240,7 +7240,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "EmailsOnPushService", "category": "common", "default": false, @@ -7262,7 +7262,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "DroneCiService", "category": "ci", "default": false, @@ -7284,7 +7284,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "CustomIssueTrackerService", "category": "issue_tracker", "default": false, @@ -7306,7 +7306,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "CampfireService", "category": "common", "default": false, @@ -7328,7 +7328,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "BuildkiteService", "category": "ci", "default": false, @@ -7350,7 +7350,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "BambooService", "category": "ci", "default": false, @@ -7372,7 +7372,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "AssemblaService", "category": "common", "default": false, @@ -7394,7 +7394,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "type": "AssemblaService", "category": "common", "default": false, @@ -7416,7 +7416,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "job_events": true, "category": "common", "default": false, "wiki_page_events": true, diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb index 744fed44925..5417c7534ea 100644 --- a/spec/lib/gitlab/import_export/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::ImportExport::RelationFactory, lib: true do 'tag_push_events' => false, 'note_events' => true, 'enable_ssl_verification' => true, - 'build_events' => false, + 'job_events' => false, 'wiki_page_events' => true, 'token' => token } diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 2b95f76e045..c22fba11225 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -292,7 +292,7 @@ Service: - tag_push_events - note_events - pipeline_events -- build_events +- job_events - category - default - wiki_page_events @@ -312,7 +312,7 @@ ProjectHook: - note_events - pipeline_events - enable_ssl_verification -- build_events +- job_events - wiki_page_events - token - group_id diff --git a/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb b/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb deleted file mode 100644 index 57eb03e3c80..00000000000 --- a/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'spec_helper' -require Rails.root.join('db', 'post_migrate', '20170301205640_migrate_build_events_to_pipeline_events.rb') - -# This migration uses multiple threads, and thus different transactions. This -# means data created in this spec may not be visible to some threads. To work -# around this we use the TRUNCATE cleaning strategy. -describe MigrateBuildEventsToPipelineEvents, truncate: true do - let(:migration) { described_class.new } - let(:project_with_pipeline_service) { create(:empty_project) } - let(:project_with_build_service) { create(:empty_project) } - - before do - ActiveRecord::Base.connection.execute <<-SQL - INSERT INTO services (properties, build_events, pipeline_events, type) - VALUES - ('{"notify_only_broken_builds":true}', true, false, 'SlackService') - , ('{"notify_only_broken_builds":true}', true, false, 'MattermostService') - , ('{"notify_only_broken_builds":true}', true, false, 'HipchatService') - ; - SQL - - ActiveRecord::Base.connection.execute <<-SQL - INSERT INTO services - (properties, build_events, pipeline_events, type, project_id) - VALUES - ('{"notify_only_broken_builds":true}', true, false, - 'BuildsEmailService', #{project_with_pipeline_service.id}) - , ('{"notify_only_broken_pipelines":true}', false, true, - 'PipelinesEmailService', #{project_with_pipeline_service.id}) - , ('{"notify_only_broken_builds":true}', true, false, - 'BuildsEmailService', #{project_with_build_service.id}) - ; - SQL - end - - describe '#up' do - before do - silence_migration = Module.new do - # rubocop:disable Rails/Delegate - def execute(query) - connection.execute(query) - end - end - - migration.extend(silence_migration) - migration.up - end - - it 'migrates chat service properly' do - [SlackService, MattermostService, HipchatService].each do |service| - expect(service.count).to eq(1) - - verify_service_record(service.first) - end - end - - it 'migrates pipelines email service only if it has none before' do - Project.find_each do |project| - pipeline_service_count = - project.services.where(type: 'PipelinesEmailService').count - - expect(pipeline_service_count).to eq(1) - - verify_service_record(project.pipelines_email_service) - end - end - - def verify_service_record(service) - expect(service.notify_only_broken_pipelines).to be(true) - expect(service.build_events).to be(false) - expect(service.pipeline_events).to be(true) - end - end -end diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb index a6641970e1b..92fbf64a6b7 100644 --- a/spec/models/blob_viewer/base_spec.rb +++ b/spec/models/blob_viewer/base_spec.rb @@ -7,11 +7,12 @@ describe BlobViewer::Base, model: true do let(:viewer_class) do Class.new(described_class) do + include BlobViewer::ServerSide + self.extensions = %w(pdf) self.binary = true - self.max_size = 1.megabyte - self.absolute_max_size = 5.megabytes - self.client_side = false + self.overridable_max_size = 1.megabyte + self.max_size = 5.megabytes end end @@ -38,10 +39,10 @@ describe BlobViewer::Base, model: true do context 'when the file type is supported' do before do - viewer_class.file_type = :license + viewer_class.file_types = %i(license) viewer_class.binary = false end - + context 'when the binaryness matches' do let(:blob) { fake_blob(path: 'LICENSE', binary: false) } @@ -68,45 +69,45 @@ describe BlobViewer::Base, model: true do end end - describe '#too_large?' do - context 'when the blob size is larger than the max size' do + describe '#exceeds_overridable_max_size?' do + context 'when the blob size is larger than the overridable max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns true' do - expect(viewer.too_large?).to be_truthy + expect(viewer.exceeds_overridable_max_size?).to be_truthy end end - context 'when the blob size is smaller than the max size' do + context 'when the blob size is smaller than the overridable max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } it 'returns false' do - expect(viewer.too_large?).to be_falsey + expect(viewer.exceeds_overridable_max_size?).to be_falsey end end end - describe '#absolutely_too_large?' do - context 'when the blob size is larger than the absolute max size' do + describe '#exceeds_max_size?' do + context 'when the blob size is larger than the max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } it 'returns true' do - expect(viewer.absolutely_too_large?).to be_truthy + expect(viewer.exceeds_max_size?).to be_truthy end end - context 'when the blob size is smaller than the absolute max size' do + context 'when the blob size is smaller than the max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns false' do - expect(viewer.absolutely_too_large?).to be_falsey + expect(viewer.exceeds_max_size?).to be_falsey end end end describe '#can_override_max_size?' do - context 'when the blob size is larger than the max size' do - context 'when the blob size is larger than the absolute max size' do + context 'when the blob size is larger than the overridable max size' do + context 'when the blob size is larger than the max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } it 'returns false' do @@ -114,7 +115,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the blob size is smaller than the absolute max size' do + context 'when the blob size is smaller than the max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns true' do @@ -123,7 +124,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the blob size is smaller than the max size' do + context 'when the blob size is smaller than the overridable max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } it 'returns false' do @@ -138,7 +139,7 @@ describe BlobViewer::Base, model: true do viewer.override_max_size = true end - context 'when the blob size is larger than the absolute max size' do + context 'when the blob size is larger than the max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } it 'returns :too_large' do @@ -146,7 +147,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the blob size is smaller than the absolute max size' do + context 'when the blob size is smaller than the max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns nil' do @@ -156,7 +157,7 @@ describe BlobViewer::Base, model: true do end context 'when the max size is not overridden' do - context 'when the blob size is larger than the max size' do + context 'when the blob size is larger than the overridable max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns :too_large' do @@ -164,7 +165,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the blob size is smaller than the max size' do + context 'when the blob size is smaller than the overridable max size' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } it 'returns nil' do @@ -172,19 +173,5 @@ describe BlobViewer::Base, model: true do end end end - - context 'when the viewer is server side but the blob is stored externally' do - let(:project) { build(:empty_project, lfs_enabled: true) } - - let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } - - before do - allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) - end - - it 'return :server_side_but_stored_externally' do - expect(viewer.render_error).to eq(:server_side_but_stored_externally) - end - end end end diff --git a/spec/models/blob_viewer/server_side_spec.rb b/spec/models/blob_viewer/server_side_spec.rb index ddca9b79390..f047953d540 100644 --- a/spec/models/blob_viewer/server_side_spec.rb +++ b/spec/models/blob_viewer/server_side_spec.rb @@ -22,4 +22,20 @@ describe BlobViewer::ServerSide, model: true do subject.prepare! end end + + describe '#render_error' do + context 'when the blob is stored externally' do + let(:project) { build(:empty_project, lfs_enabled: true) } + + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + it 'return :server_side_but_stored_externally' do + expect(subject.render_error).to eq(:server_side_but_stored_externally) + end + end + end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 61b748429d7..718b7d5e86b 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -110,22 +110,11 @@ describe Repository, models: true do end describe '#ref_name_for_sha' do - context 'ref found' do - it 'returns the ref' do - allow_any_instance_of(Gitlab::Popen).to receive(:popen). - and_return(["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]) + it 'returns the ref' do + allow(repository.raw_repository).to receive(:ref_name_for_sha). + and_return('refs/environments/production/77') - expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77' - end - end - - context 'ref not found' do - it 'returns nil' do - allow_any_instance_of(Gitlab::Popen).to receive(:popen). - and_return(["", 0]) - - expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq nil - end + expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77' end end @@ -1917,12 +1906,18 @@ describe Repository, models: true do describe '#is_ancestor?' do context 'Gitaly is_ancestor feature enabled' do - it "asks Gitaly server if it's an ancestor" do - commit = repository.commit - expect(repository.raw_repository).to receive(:is_ancestor?).and_call_original + let(:commit) { repository.commit } + let(:ancestor) { commit.parents.first } + + before do + allow(Gitlab::GitalyClient).to receive(:enabled?).and_return(true) allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:is_ancestor).and_return(true) + end + + it "asks Gitaly server if it's an ancestor" do + expect_any_instance_of(Gitlab::GitalyClient::Commit).to receive(:is_ancestor).with(ancestor.id, commit.id) - expect(repository.is_ancestor?(commit.id, commit.id)).to be true + repository.is_ancestor?(ancestor.id, commit.id) end end end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index aee0e17a153..0f9330b062d 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -60,7 +60,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) - expect(json_response['job_events']).to eq(hook.build_events) + expect(json_response['job_events']).to eq(hook.job_events) expect(json_response['pipeline_events']).to eq(hook.pipeline_events) expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) @@ -148,7 +148,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) - expect(json_response['job_events']).to eq(hook.build_events) + expect(json_response['job_events']).to eq(hook.job_events) expect(json_response['pipeline_events']).to eq(hook.pipeline_events) expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) diff --git a/spec/requests/api/v3/project_hooks_spec.rb b/spec/requests/api/v3/project_hooks_spec.rb index a3a4c77d09d..1969d1c7f2b 100644 --- a/spec/requests/api/v3/project_hooks_spec.rb +++ b/spec/requests/api/v3/project_hooks_spec.rb @@ -58,7 +58,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) - expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['build_events']).to eq(hook.job_events) expect(json_response['pipeline_events']).to eq(hook.pipeline_events) expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) @@ -143,7 +143,7 @@ describe API::ProjectHooks, 'ProjectHooks' do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) - expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['build_events']).to eq(hook.job_events) expect(json_response['pipeline_events']).to eq(hook.pipeline_events) expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) diff --git a/spec/support/dropzone_helper.rb b/spec/support/dropzone_helper.rb index 984ec7d2741..02fdeb08afe 100644 --- a/spec/support/dropzone_helper.rb +++ b/spec/support/dropzone_helper.rb @@ -6,32 +6,52 @@ module DropzoneHelper # Dropzone events to perform the actual upload. # # This method waits for the upload to complete before returning. - def dropzone_file(file_path) + # max_file_size is an optional parameter. + # If it's not 0, then it used in dropzone.maxFilesize parameter. + # wait_for_queuecomplete is an optional parameter. + # If it's 'false', then the helper will NOT wait for backend response + # It lets to test behaviors while AJAX is processing. + def dropzone_file(files, max_file_size = 0, wait_for_queuecomplete = true) # Generate a fake file input that Capybara can attach to page.execute_script <<-JS.strip_heredoc + $('#fakeFileInput').remove(); var fakeFileInput = window.$('<input/>').attr( - {id: 'fakeFileInput', type: 'file'} + {id: 'fakeFileInput', type: 'file', multiple: true} ).appendTo('body'); window._dropzoneComplete = false; JS - # Attach the file to the fake input selector with Capybara - attach_file('fakeFileInput', file_path) + # Attach files to the fake input selector with Capybara + attach_file('fakeFileInput', files) # Manually trigger a Dropzone "drop" event with the fake input's file list page.execute_script <<-JS.strip_heredoc - var fileList = [$('#fakeFileInput')[0].files[0]]; - var e = jQuery.Event('drop', { dataTransfer : { files : fileList } }); - var dropzone = $('.div-dropzone')[0].dropzone; + dropzone.options.autoProcessQueue = false; + + if (#{max_file_size} > 0) { + dropzone.options.maxFilesize = #{max_file_size}; + } + dropzone.on('queuecomplete', function() { window._dropzoneComplete = true; }); - dropzone.listeners[0].events.drop(e); + + var fileList = [$('#fakeFileInput')[0].files]; + + $.map(fileList, function(file){ + var e = jQuery.Event('drop', { dataTransfer : { files : file } }); + + dropzone.listeners[0].events.drop(e); + }); + + dropzone.processQueue(); JS - # Wait until Dropzone's fired `queuecomplete` - loop until page.evaluate_script('window._dropzoneComplete === true') + if wait_for_queuecomplete + # Wait until Dropzone's fired `queuecomplete` + loop until page.evaluate_script('window._dropzoneComplete === true') + end end end diff --git a/spec/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb index 08018767624..c6b0ed8da3c 100644 --- a/spec/views/projects/blob/_viewer.html.haml_spec.rb +++ b/spec/views/projects/blob/_viewer.html.haml_spec.rb @@ -10,9 +10,9 @@ describe 'projects/blob/_viewer.html.haml', :view do include BlobViewer::Rich self.partial_name = 'text' - self.max_size = 1.megabyte - self.absolute_max_size = 5.megabytes - self.client_side = false + self.overridable_max_size = 1.megabyte + self.max_size = 5.megabytes + self.load_async = true end end @@ -35,9 +35,9 @@ describe 'projects/blob/_viewer.html.haml', :view do render partial: 'projects/blob/viewer', locals: { viewer: viewer } end - context 'when the viewer is server side' do + context 'when the viewer is loaded asynchronously' do before do - viewer_class.client_side = false + viewer_class.load_async = true end context 'when there is no render error' do @@ -65,9 +65,9 @@ describe 'projects/blob/_viewer.html.haml', :view do end end - context 'when the viewer is client side' do + context 'when the viewer is loaded synchronously' do before do - viewer_class.client_side = true + viewer_class.load_async = false end context 'when there is no render error' do |