diff options
Diffstat (limited to 'app/assets')
67 files changed, 1643 insertions, 685 deletions
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js new file mode 100644 index 00000000000..cdea625fc8c --- /dev/null +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -0,0 +1,73 @@ +import Clipboard from 'clipboard'; + +function showTooltip(target, title) { + const $target = $(target); + const originalTitle = $target.data('original-title'); + + if (!$target.data('hideTooltip')) { + $target + .attr('title', title) + .tooltip('fixTitle') + .tooltip('show') + .attr('title', originalTitle) + .tooltip('fixTitle'); + } +} + +function genericSuccess(e) { + showTooltip(e.trigger, 'Copied'); + // Clear the selection and blur the trigger so it loses its border + e.clearSelection(); + $(e.trigger).blur(); +} + +/** + * Safari > 10 doesn't support `execCommand`, so instead we inform the user to copy manually. + * See http://clipboardjs.com/#browser-support + */ +function genericError(e) { + let key; + if (/Mac/i.test(navigator.userAgent)) { + key = '⌘'; // Command + } else { + key = 'Ctrl'; + } + showTooltip(e.trigger, `Press ${key}-C to copy`); +} + +export default function initCopyToClipboard() { + const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); + clipboard.on('success', genericSuccess); + clipboard.on('error', genericError); + + /** + * This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting + * of plain text or GFM. The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and + * `gfm` keys into the `data-clipboard-text` attribute that ClipboardJS reads from. + * When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` + * attribute`), sets its value to the value of this data attribute, focusses on it, and finally + * programmatically issues the 'Copy' command, this code intercepts the copy command/event at + * the last minute to deconstruct this JSON hash and set the `text/plain` and `text/x-gfm` copy + * data types to the intended values. + */ + $(document).on('copy', 'body > textarea[readonly]', (e) => { + const clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + const text = e.target.value; + + let json; + try { + json = JSON.parse(text); + } catch (ex) { + return; + } + + if (!json.text || !json.gfm) return; + + e.preventDefault(); + + clipboardData.setData('text/plain', json.text); + clipboardData.setData('text/x-gfm', json.gfm); + }); +} diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 671532394a9..34e905222b4 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -1,6 +1,7 @@ import './autosize'; import './bind_in_out'; import initCopyAsGFM from './copy_as_gfm'; +import initCopyToClipboard from './copy_to_clipboard'; import './details_behavior'; import installGlEmojiElement from './gl_emoji'; import './quick_submit'; @@ -9,3 +10,4 @@ import './toggler_behavior'; installGlEmojiElement(); initCopyAsGFM(); +initCopyToClipboard(); diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js deleted file mode 100644 index 1f3c7e1772d..00000000000 --- a/app/assets/javascripts/copy_to_clipboard.js +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, prefer-arrow-callback, max-len */ - -import Clipboard from 'vendor/clipboard'; - -var genericError, genericSuccess, showTooltip; - -genericSuccess = function(e) { - showTooltip(e.trigger, 'Copied'); - // Clear the selection and blur the trigger so it loses its border - e.clearSelection(); - return $(e.trigger).blur(); -}; - -// Safari doesn't support `execCommand`, so instead we inform the user to -// copy manually. -// -// See http://clipboardjs.com/#browser-support -genericError = function(e) { - var key; - if (/Mac/i.test(navigator.userAgent)) { - key = '⌘'; // Command - } else { - key = 'Ctrl'; - } - return showTooltip(e.trigger, "Press " + key + "-C to copy"); -}; - -showTooltip = function(target, title) { - var $target = $(target); - var originalTitle = $target.data('original-title'); - - if (!$target.data('hideTooltip')) { - $target - .attr('title', 'Copied') - .tooltip('fixTitle') - .tooltip('show') - .attr('title', originalTitle) - .tooltip('fixTitle'); - } -}; - -$(function() { - const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); - clipboard.on('success', genericSuccess); - clipboard.on('error', genericError); - - // This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM. - // The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text` - // attribute that ClipboardJS reads from. - // When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value - // to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command, - // this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the - // `text/plain` and `text/x-gfm` copy data types to the intended values. - $(document).on('copy', 'body > textarea[readonly]', function(e) { - const clipboardData = e.originalEvent.clipboardData; - if (!clipboardData) return; - - const text = e.target.value; - - let json; - try { - json = JSON.parse(text); - } catch (ex) { - return; - } - - if (!json.text || !json.gfm) return; - - e.preventDefault(); - - clipboardData.setData('text/plain', json.text); - clipboardData.setData('text/x-gfm', json.gfm); - }); -}); diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index bf40eb3ee11..23425672b16 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -2,6 +2,7 @@ import Flash from './flash'; import DropLab from './droplab/drop_lab'; import ISetter from './droplab/plugins/input_setter'; +import { __, sprintf } from './locale'; // Todo: Remove this when fixing issue in input_setter plugin const InputSetter = Object.assign({}, ISetter); @@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch'; export default class CreateMergeRequestDropdown { constructor(wrapperEl) { this.wrapperEl = wrapperEl; + this.availableButton = this.wrapperEl.querySelector('.available'); + this.branchInput = this.wrapperEl.querySelector('.js-branch-name'); + this.branchMessage = this.wrapperEl.querySelector('.js-branch-message'); this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); - this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.createTargetButton = this.wrapperEl.querySelector('.js-create-target'); this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); - this.availableButton = this.wrapperEl.querySelector('.available'); + this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.refInput = this.wrapperEl.querySelector('.js-ref'); + this.refMessage = this.wrapperEl.querySelector('.js-ref-message'); this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); this.unavailableButtonText = this.unavailableButton.querySelector('.text'); - this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.branchCreated = false; + this.branchIsValid = true; this.canCreatePath = this.wrapperEl.dataset.canCreatePath; + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; this.createMrPath = this.wrapperEl.dataset.createMrPath; this.droplabInitialized = false; + this.isCreatingBranch = false; this.isCreatingMergeRequest = false; + this.isGettingRef = false; this.mergeRequestCreated = false; - this.isCreatingBranch = false; - this.branchCreated = false; + this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500); + this.refIsValid = true; + this.refsPath = this.wrapperEl.dataset.refsPath; + this.suggestedRef = this.refInput.value; - this.init(); - } + // These regexps are used to replace + // a backend generated new branch name and its source (ref) + // with user's inputs. + this.regexps = { + branch: { + createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'), + createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'), + }, + ref: { + createBranchPath: new RegExp('(ref=)(.+?)$'), + createMrPath: new RegExp('(ref=)(.+?)$'), + }, + }; - init() { - this.checkAbilityToCreateBranch(); + this.init(); } available() { @@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown { this.unavailableButton.classList.add('hide'); } - unavailable() { - this.availableButton.classList.add('hide'); - this.unavailableButton.classList.remove('hide'); - } - - enable() { - this.createMergeRequestButton.classList.remove('disabled'); - this.createMergeRequestButton.removeAttribute('disabled'); - - this.dropdownToggle.classList.remove('disabled'); - this.dropdownToggle.removeAttribute('disabled'); - } - - disable() { - this.createMergeRequestButton.classList.add('disabled'); - this.createMergeRequestButton.setAttribute('disabled', 'disabled'); - - this.dropdownToggle.classList.add('disabled'); - this.dropdownToggle.setAttribute('disabled', 'disabled'); - } - - hide() { - this.wrapperEl.classList.add('hide'); - } - - setUnavailableButtonState(isLoading = true) { - if (isLoading) { - this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin'); - this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); - this.unavailableButtonText.textContent = 'Checking branch availability…'; - } else { - this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin'); - this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); - this.unavailableButtonText.textContent = 'New branch unavailable'; - } + bindEvents() { + this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this)); + this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this)); + this.refInput.addEventListener('keyup', this.onChangeInput.bind(this)); + this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this)); } checkAbilityToCreateBranch() { @@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown { }); } - initDroplab() { - this.droplab = new DropLab(); + createBranch() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createBranchPath, + beforeSend: () => (this.isCreatingBranch = true), + }) + .done((data) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + } - this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter], - this.getDroplabConfig()); + createMergeRequest() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createMrPath, + beforeSend: () => (this.isCreatingMergeRequest = true), + }) + .done((data) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + } + + disable() { + this.disableCreateAction(); + + this.dropdownToggle.classList.add('disabled'); + this.dropdownToggle.setAttribute('disabled', 'disabled'); + } + + disableCreateAction() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.createTargetButton.classList.add('disabled'); + this.createTargetButton.setAttribute('disabled', 'disabled'); + } + + enable() { + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.createTargetButton.classList.remove('disabled'); + this.createTargetButton.removeAttribute('disabled'); + + this.dropdownToggle.classList.remove('disabled'); + this.dropdownToggle.removeAttribute('disabled'); + } + + static findByValue(objects, ref, returnFirstMatch = false) { + if (!objects || !objects.length) return false; + if (objects.indexOf(ref) > -1) return ref; + if (returnFirstMatch) return objects.find(item => new RegExp(`^${ref}`).test(item)); + + return false; } getDroplabConfig() { return { - InputSetter: [{ - input: this.createMergeRequestButton, - valueAttribute: 'data-value', - inputAttribute: 'data-action', - }, { - input: this.createMergeRequestButton, - valueAttribute: 'data-text', - }], + addActiveClassToDropdownButton: true, + InputSetter: [ + { + input: this.createMergeRequestButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, + { + input: this.createMergeRequestButton, + valueAttribute: 'data-text', + }, + { + input: this.createTargetButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, + { + input: this.createTargetButton, + valueAttribute: 'data-text', + }, + ], }; } - bindEvents() { - this.createMergeRequestButton - .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + static getInputSelectedText(input) { + const start = input.selectionStart; + const end = input.selectionEnd; + + return input.value.substr(start, end - start); + } + + getRef(ref, target = 'all') { + if (!ref) return false; + + return $.ajax({ + method: 'GET', + dataType: 'json', + url: this.refsPath + ref, + beforeSend: () => { + this.isGettingRef = true; + }, + }) + .always(() => { + this.isGettingRef = false; + }) + .done((data) => { + const branches = data[Object.keys(data)[0]]; + const tags = data[Object.keys(data)[1]]; + let result; + + if (target === 'branch') { + result = CreateMergeRequestDropdown.findByValue(branches, ref); + } else { + result = CreateMergeRequestDropdown.findByValue(branches, ref, true) || + CreateMergeRequestDropdown.findByValue(tags, ref, true); + this.suggestedRef = result; + } + + return this.updateInputState(target, ref, result); + }) + .fail(() => { + this.unavailable(); + this.disable(); + new Flash('Failed to get ref.'); + + return false; + }); + } + + getTargetData(target) { + return { + input: this[`${target}Input`], + message: this[`${target}Message`], + }; + } + + hide() { + this.wrapperEl.classList.add('hide'); + } + + init() { + this.checkAbilityToCreateBranch(); + } + + initDroplab() { + this.droplab = new DropLab(); + + this.droplab.init( + this.dropdownToggle, + this.dropdownList, + [InputSetter], + this.getDroplabConfig(), + ); + } + + inputsAreValid() { + return this.branchIsValid && this.refIsValid; } isBusy() { return this.isCreatingMergeRequest || this.mergeRequestCreated || this.isCreatingBranch || - this.branchCreated; + this.branchCreated || + this.isGettingRef; } - onClickCreateMergeRequestButton(e) { + onChangeInput(event) { + let target; + let value; + + if (event.srcElement === this.branchInput) { + target = 'branch'; + value = this.branchInput.value; + } else if (event.srcElement === this.refInput) { + target = 'ref'; + value = event.srcElement.value.slice(0, event.srcElement.selectionStart) + + event.srcElement.value.slice(event.srcElement.selectionEnd); + } else { + return false; + } + + if (this.isGettingRef) return false; + + // `ENTER` key submits the data. + if (event.keyCode === 13 && this.inputsAreValid()) { + event.preventDefault(); + return this.createMergeRequestButton.click(); + } + + // If the input is empty, use the original value generated by the backend. + if (!value) { + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + + if (target === 'branch') { + this.branchIsValid = true; + } else { + this.refIsValid = true; + } + + this.enable(); + this.showAvailableMessage(target); + return true; + } + + this.showCheckingMessage(target); + this.refDebounce(value, target); + + return true; + } + + onClickCreateMergeRequestButton(event) { let xhr = null; - e.preventDefault(); + event.preventDefault(); if (this.isBusy()) { return; } - if (e.target.dataset.action === CREATE_MERGE_REQUEST) { + if (event.target.dataset.action === CREATE_MERGE_REQUEST) { xhr = this.createMergeRequest(); - } else if (e.target.dataset.action === CREATE_BRANCH) { + } else if (event.target.dataset.action === CREATE_BRANCH) { xhr = this.createBranch(); } @@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown { this.disable(); } - createMergeRequest() { - return $.ajax({ - method: 'POST', - dataType: 'json', - url: this.createMrPath, - beforeSend: () => (this.isCreatingMergeRequest = true), - }) - .done((data) => { - this.mergeRequestCreated = true; - window.location.href = data.url; - }) - .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + onClickSetFocusOnBranchNameInput() { + this.branchInput.focus(); } - createBranch() { - return $.ajax({ - method: 'POST', - dataType: 'json', - url: this.createBranchPath, - beforeSend: () => (this.isCreatingBranch = true), - }) - .done((data) => { - this.branchCreated = true; - window.location.href = data.url; - }) - .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + // `TAB` autocompletes the source. + static processTab(event) { + if (event.keyCode !== 9 || this.isGettingRef) return; + + const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput); + + // if nothing selected, we don't need to autocomplete anything. Do the default TAB action. + // If a user manually selected text, don't autocomplete anything. Do the default TAB action. + if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return; + + event.preventDefault(); + window.getSelection().removeAllRanges(); + } + + removeMessage(target) { + const { input, message } = this.getTargetData(target); + const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline']; + const messageClasses = ['gl-field-hint', 'gl-field-error-message', 'gl-field-success-message']; + + inputClasses.forEach(cssClass => input.classList.remove(cssClass)); + messageClasses.forEach(cssClass => message.classList.remove(cssClass)); + message.style.display = 'none'; + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonArrow.classList.add('fa-spin'); + this.unavailableButtonArrow.classList.add('fa-spinner'); + this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = __('Checking branch availability...'); + } else { + this.unavailableButtonArrow.classList.remove('fa-spin'); + this.unavailableButtonArrow.classList.remove('fa-spinner'); + this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = __('New branch unavailable'); + } + } + + showAvailableMessage(target) { + const { input, message } = this.getTargetData(target); + const text = target === 'branch' ? __('Branch name') : __('Source'); + + this.removeMessage(target); + input.classList.add('gl-field-success-outline'); + message.classList.add('gl-field-success-message'); + message.textContent = sprintf(__('%{text} is available'), { text }); + message.style.display = 'inline-block'; + } + + showCheckingMessage(target) { + const { message } = this.getTargetData(target); + const text = target === 'branch' ? __('branch name') : __('source'); + + this.removeMessage(target); + message.classList.add('gl-field-hint'); + message.textContent = sprintf(__('Checking %{text} availability…'), { text }); + message.style.display = 'inline-block'; + } + + showNotAvailableMessage(target) { + const { input, message } = this.getTargetData(target); + const text = target === 'branch' ? __('Branch is already taken') : __('Source is not available'); + + this.removeMessage(target); + input.classList.add('gl-field-error-outline'); + message.classList.add('gl-field-error-message'); + message.textContent = text; + message.style.display = 'inline-block'; + } + + unavailable() { + this.availableButton.classList.add('hide'); + this.unavailableButton.classList.remove('hide'); + } + + updateInputState(target, ref, result) { + // target - 'branch' or 'ref' - which the input field we are searching a ref for. + // ref - string - what a user typed. + // result - string - what has been found on backend. + + const pathReplacement = `$1${ref}`; + + // If a found branch equals exact the same text a user typed, + // that means a new branch cannot be created as it already exists. + if (ref === result) { + if (target === 'branch') { + this.branchIsValid = false; + this.showNotAvailableMessage('branch'); + } else { + this.refIsValid = true; + this.refInput.dataset.value = ref; + this.showAvailableMessage('ref'); + this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath, + pathReplacement); + this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath, + pathReplacement); + } + } else if (target === 'branch') { + this.branchIsValid = true; + this.showAvailableMessage('branch'); + this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath, + pathReplacement); + this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath, + pathReplacement); + } else { + this.refIsValid = false; + this.refInput.dataset.value = ref; + this.disableCreateAction(); + this.showNotAvailableMessage('ref'); + + // Show ref hint. + if (result) { + this.refInput.value = result; + this.refInput.setSelectionRange(ref.length, result.length); + } + } + + if (this.inputsAreValid()) { + this.enable(); + } else { + this.disableCreateAction(); + } } } diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 34708977d20..a21c92f24d6 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -383,6 +383,7 @@ import ProjectVariables from './project_variables'; projectImport(); break; case 'projects:pipelines:new': + case 'projects:pipelines:create': new NewBranchForm($('.js-new-pipeline-form')); break; case 'projects:pipelines:builds': @@ -521,6 +522,13 @@ import ProjectVariables from './project_variables'; case 'projects:settings:ci_cd:show': // Initialize expandable settings panels initSettingsPanels(); + + import(/* webpackChunkName: "ci-cd-settings" */ './projects/ci_cd_settings_bundle') + .then(ciCdSettings => ciCdSettings.default()) + .catch((err) => { + Flash(s__('ProjectSettings|Problem setting up the CI/CD settings JavaScript')); + throw err; + }); case 'groups:settings:ci_cd:show': new ProjectVariables(); break; diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index 868d47e91b3..673e9bb4c0f 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; const IGNORE_CLASS = 'droplab-item-ignore'; +const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding'; // Matches `{{anything}}` and `{{ everything }}`. const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; @@ -13,4 +14,5 @@ export { ACTIVE_CLASS, TEMPLATE_REGEX, IGNORE_CLASS, + IGNORE_HIDING_CLASS, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 3901bb177fe..5eb0a339a1c 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -1,15 +1,18 @@ import utils from './utils'; -import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; +import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants'; class DropDown { - constructor(list) { + constructor(list, config = {}) { this.currentIndex = 0; this.hidden = true; this.list = typeof list === 'string' ? document.querySelector(list) : list; this.items = []; - this.eventWrapper = {}; + if (config.addActiveClassToDropdownButton) { + this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle'); + } + this.getItems(); this.initTemplateString(); this.addEvents(); @@ -42,7 +45,7 @@ class DropDown { this.addSelectedClass(selected); e.preventDefault(); - this.hide(); + if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide(); const listEvent = new CustomEvent('click.dl', { detail: { @@ -67,7 +70,20 @@ class DropDown { addEvents() { this.eventWrapper.clickEvent = this.clickEvent.bind(this); + this.eventWrapper.closeDropdown = this.closeDropdown.bind(this); + this.list.addEventListener('click', this.eventWrapper.clickEvent); + this.list.addEventListener('keyup', this.eventWrapper.closeDropdown); + } + + closeDropdown(event) { + // `ESC` key closes the dropdown. + if (event.keyCode === 27) { + event.preventDefault(); + return this.toggle(); + } + + return true; } setData(data) { @@ -110,6 +126,8 @@ class DropDown { this.list.style.display = 'block'; this.currentIndex = 0; this.hidden = false; + + if (this.dropdownToggle) this.dropdownToggle.classList.add('active'); } hide() { @@ -117,6 +135,8 @@ class DropDown { this.list.style.display = 'none'; this.currentIndex = 0; this.hidden = true; + + if (this.dropdownToggle) this.dropdownToggle.classList.remove('active'); } toggle() { @@ -128,6 +148,7 @@ class DropDown { destroy() { this.hide(); this.list.removeEventListener('click', this.eventWrapper.clickEvent); + this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown); } static setImagesSrc(template) { diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js index cf78165b0d8..8a8dcde9f88 100644 --- a/app/assets/javascripts/droplab/hook.js +++ b/app/assets/javascripts/droplab/hook.js @@ -3,7 +3,7 @@ import DropDown from './drop_down'; class Hook { constructor(trigger, list, plugins, config) { this.trigger = trigger; - this.list = new DropDown(list); + this.list = new DropDown(list, config); this.type = 'Hook'; this.event = 'click'; this.plugins = plugins || []; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 67261c1c9b4..44deab9288e 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -41,7 +41,7 @@ const createFlashEl = (message, type, isInContentWrapper = false) => ` `; const removeFlashClickListener = (flashEl, fadeTransition) => { - flashEl.parentNode.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); + flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); }; /* diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 1f9571ff413..5bdc7c99503 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -16,6 +16,10 @@ export default { required: true, type: String, }, + updateEndpoint: { + required: true, + type: String, + }, canUpdate: { required: true, type: Boolean, @@ -262,6 +266,8 @@ export default { :description-text="state.descriptionText" :updated-at="state.updatedAt" :task-status="state.taskStatus" + :issuable-type="issuableType" + :update-url="updateEndpoint" /> <edited-component v-if="hasUpdated" diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 48bad8f1e68..b7559ced946 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -22,6 +22,16 @@ required: false, default: '', }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + updateUrl: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -48,7 +58,7 @@ if (this.canUpdate) { // eslint-disable-next-line no-new new TaskList({ - dataType: 'issue', + dataType: this.issuableType, fieldName: 'description', selector: '.detail-page-description', }); @@ -95,7 +105,9 @@ <textarea class="hidden js-task-list-field" v-if="descriptionText" - v-model="descriptionText"> + v-model="descriptionText" + :data-update-url="updateUrl" + > </textarea> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 00002709ac6..a363d06d950 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -79,7 +79,7 @@ v-tooltip v-if="showInlineEditButton && canUpdate" type="button" - class="btn-blank btn-edit note-action-button" + class="btn btn-default btn-edit btn-svg" v-html="pencilIcon" title="Edit title and description" data-placement="bottom" diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 6fa1e84c170..33cc807912c 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -190,7 +190,7 @@ export const insertText = (target, text) => { target.selectionStart = target.selectionEnd = selectionStart + insertedText.length; // Trigger autosave - $(target).trigger('input'); + target.dispatchEvent(new Event('input')); // Trigger autosize const event = document.createEvent('Event'); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 08e326cba9c..dcc0fa63b63 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -44,7 +44,6 @@ import './commits'; import './compare'; import './compare_autocomplete'; import './confirm_danger_modal'; -import './copy_to_clipboard'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; import './gl_field_error'; @@ -301,6 +300,8 @@ $(function () { const flashContainer = document.querySelector('.flash-container'); if (flashContainer && flashContainer.children.length) { - removeFlashClickListener(flashContainer.children[0]); + flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => { + removeFlashClickListener(flashEl); + }); } }); diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue index 30e02554b65..dbc900f4c04 100644 --- a/app/assets/javascripts/notes/components/issue_comment_form.vue +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -22,7 +22,7 @@ noteType: constants.COMMENT, // Can't use mapGetters, // this needs to be in the data object because it belongs to the state - issueState: this.$store.getters.getIssueData.state, + issueState: this.$store.getters.getNoteableData.state, isSubmitting: false, isSubmitButtonDisabled: true, }; @@ -46,7 +46,7 @@ ...mapGetters([ 'getCurrentUserLastNote', 'getUserData', - 'getIssueData', + 'getNoteableData', 'getNotesData', ]), isLoggedIn() { @@ -59,7 +59,7 @@ return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; }, canCreateNote() { - return this.getIssueData.current_user.can_create_note; + return this.getNoteableData.current_user.can_create_note; }, issueActionButtonTitle() { if (this.note.length) { @@ -85,16 +85,16 @@ return this.getNotesData.quickActionsDocsPath; }, markdownPreviewPath() { - return this.getIssueData.preview_note_path; + return this.getNoteableData.preview_note_path; }, author() { return this.getUserData; }, canUpdateIssue() { - return this.getIssueData.current_user.can_update; + return this.getNoteableData.current_user.can_update; }, endpoint() { - return this.getIssueData.create_note_path; + return this.getNoteableData.create_note_path; }, }, methods: { @@ -119,7 +119,7 @@ data: { note: { noteable_type: constants.NOTEABLE_TYPE, - noteable_id: this.getIssueData.id, + noteable_id: this.getNoteableData.id, note: this.note, }, }, @@ -207,7 +207,7 @@ }, initAutoSave() { if (this.isLoggedIn) { - this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue'); + this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getNoteableData.id], 'issue'); } }, initTaskList() { @@ -266,9 +266,9 @@ <div class="error-alert"></div> <issue-warning - v-if="hasWarning(getIssueData)" - :is-locked="isLocked(getIssueData)" - :is-confidential="isConfidential(getIssueData)" + v-if="hasWarning(getNoteableData)" + :is-locked="isLocked(getNoteableData)" + :is-confidential="isConfidential(getNoteableData)" /> <markdown-field diff --git a/app/assets/javascripts/notes/components/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue index 0f13221b81e..26f729d8875 100644 --- a/app/assets/javascripts/notes/components/issue_discussion.vue +++ b/app/assets/javascripts/notes/components/issue_discussion.vue @@ -41,7 +41,7 @@ ], computed: { ...mapGetters([ - 'getIssueData', + 'getNoteableData', ]), discussion() { return this.note.notes[0]; @@ -50,10 +50,10 @@ return this.discussion.author; }, canReply() { - return this.getIssueData.current_user.can_create_note; + return this.getNoteableData.current_user.can_create_note; }, newNotePath() { - return this.getIssueData.create_note_path; + return this.getNoteableData.create_note_path; }, lastUpdatedBy() { const { notes } = this.note; diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue index e2539d6b89d..4d527cb6643 100644 --- a/app/assets/javascripts/notes/components/issue_note_form.vue +++ b/app/assets/javascripts/notes/components/issue_note_form.vue @@ -46,8 +46,8 @@ computed: { ...mapGetters([ 'getDiscussionLastNote', - 'getIssueData', - 'getIssueDataByProp', + 'getNoteableData', + 'getNoteableDataByProp', 'getNotesDataByProp', 'getUserDataByProp', ]), @@ -55,7 +55,7 @@ return `#note_${this.noteId}`; }, markdownPreviewPath() { - return this.getIssueDataByProp('preview_note_path'); + return this.getNoteableDataByProp('preview_note_path'); }, markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); @@ -129,9 +129,9 @@ class="edit-note common-note-form js-quick-submit gfm-form"> <issue-warning - v-if="hasWarning(getIssueData)" - :is-locked="isLocked(getIssueData)" - :is-confidential="isConfidential(getIssueData)" + v-if="hasWarning(getNoteableData)" + :is-locked="isLocked(getNoteableData)" + :is-confidential="isConfidential(getNoteableData)" /> <markdown-field diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/issue_notes_app.vue index 5c9119644e3..4cfcffa2391 100644 --- a/app/assets/javascripts/notes/components/issue_notes_app.vue +++ b/app/assets/javascripts/notes/components/issue_notes_app.vue @@ -14,7 +14,7 @@ export default { name: 'issueNotesApp', props: { - issueData: { + noteableData: { type: Object, required: true, }, @@ -56,7 +56,7 @@ actionToggleAward: 'toggleAward', scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', setNotesData: 'setNotesData', - setIssueData: 'setIssueData', + setNoteableData: 'setNoteableData', setUserData: 'setUserData', setLastFetchedAt: 'setLastFetchedAt', setTargetNoteHash: 'setTargetNoteHash', @@ -106,7 +106,7 @@ }, created() { this.setNotesData(this.notesData); - this.setIssueData(this.issueData); + this.setNoteableData(this.noteableData); this.setUserData(this.userData); }, mounted() { diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index e2ea37408cf..8d74c5de5cf 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -10,7 +10,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ const notesDataset = document.getElementById('js-vue-notes').dataset; return { - issueData: JSON.parse(notesDataset.issueData), + noteableData: JSON.parse(notesDataset.noteableData), currentUserData: JSON.parse(notesDataset.currentUserData), notesData: { lastFetchedAt: notesDataset.lastFetchedAt, @@ -26,7 +26,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ render(createElement) { return createElement('issue-notes-app', { props: { - issueData: this.issueData, + noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, }, diff --git a/app/assets/javascripts/notes/services/issue_notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index b51b0cb2013..b51b0cb2013 100644 --- a/app/assets/javascripts/notes/services/issue_notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 6f04aecc9b7..085b18642ba 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -4,7 +4,7 @@ import Poll from '../../lib/utils/poll'; import * as types from './mutation_types'; import * as utils from './utils'; import * as constants from '../constants'; -import service from '../services/issue_notes_service'; +import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; @@ -12,7 +12,7 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; let eTagPoll; export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data); -export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data); +export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data); export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 1f0c6af6156..e18b277119e 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -6,8 +6,8 @@ export const targetNoteHash = state => state.targetNoteHash; export const getNotesData = state => state.notesData; export const getNotesDataByProp = state => prop => state.notesData[prop]; -export const getIssueData = state => state.issueData; -export const getIssueDataByProp = state => prop => state.issueData[prop]; +export const getNoteableData = state => state.noteableData; +export const getNoteableDataByProp = state => prop => state.noteableData[prop]; export const getUserData = state => state.userData || {}; export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js index 8e0c8531bbc..488a9ca38d3 100644 --- a/app/assets/javascripts/notes/stores/index.js +++ b/app/assets/javascripts/notes/stores/index.js @@ -15,7 +15,7 @@ export default new Vuex.Store({ // holds endpoints and permissions provided through haml notesData: {}, userData: {}, - issueData: {}, + noteableData: {}, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index cd71533ba9d..d520c197407 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -3,7 +3,7 @@ export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; export const DELETE_NOTE = 'DELETE_NOTE'; export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; export const SET_NOTES_DATA = 'SET_NOTES_DATA'; -export const SET_ISSUE_DATA = 'SET_ISSUE_DATA'; +export const SET_NOTEABLE_DATA = 'SET_NOTEABLE_DATA'; export const SET_USER_DATA = 'SET_USER_DATA'; export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES'; export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index c2a08f3d6fe..20f81a430c2 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -66,8 +66,8 @@ export default { Object.assign(state, { notesData: data }); }, - [types.SET_ISSUE_DATA](state, data) { - Object.assign(state, { issueData: data }); + [types.SET_NOTEABLE_DATA](state, data) { + Object.assign(state, { noteableData: data }); }, [types.SET_USER_DATA](state, data) { diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index 36b6a5ed376..3131e71d9d6 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -17,13 +17,14 @@ export default class Project { $('a', $cloneOptions).on('click', (e) => { const $this = $(e.currentTarget); const url = $this.attr('href'); + const activeText = $this.find('.dropdown-menu-inner-title').text(); e.preventDefault(); $('.is-active', $cloneOptions).not($this).removeClass('is-active'); $this.toggleClass('is-active'); $projectCloneField.val(url); - $cloneBtnText.text($this.text()); + $cloneBtnText.text(activeText); return $('.clone').text(url); }); diff --git a/app/assets/javascripts/projects/ci_cd_settings_bundle.js b/app/assets/javascripts/projects/ci_cd_settings_bundle.js new file mode 100644 index 00000000000..90e418f6771 --- /dev/null +++ b/app/assets/javascripts/projects/ci_cd_settings_bundle.js @@ -0,0 +1,19 @@ +function updateAutoDevopsRadios(radioWrappers) { + radioWrappers.forEach((radioWrapper) => { + const radio = radioWrapper.querySelector('.js-auto-devops-enable-radio'); + const runPipelineCheckboxWrapper = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox-wrapper'); + const runPipelineCheckbox = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox'); + + if (runPipelineCheckbox) { + runPipelineCheckbox.checked = radio.checked; + runPipelineCheckboxWrapper.classList.toggle('hide', !radio.checked); + } + }); +} + +export default function initCiCdSettings() { + const radioWrappers = document.querySelectorAll('.js-auto-devops-enable-radio-wrapper'); + radioWrappers.forEach(radioWrapper => + radioWrapper.addEventListener('change', () => updateAutoDevopsRadios(radioWrappers)), + ); +} diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue index fe5179de206..d482a7025de 100644 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue @@ -48,6 +48,27 @@ export default { } return this.projectName; }, + /** + * Smartly truncates project namespace by doing two things; + * 1. Only include Group names in path by removing project name + * 2. Only include first and last group names in the path + * when namespace has more than 2 groups present + * + * First part (removal of project name from namespace) can be + * done from backend but doing so involves migration of + * existing project namespaces which is not wise thing to do. + */ + truncatedNamespace() { + const namespaceArr = this.namespace.split(' / '); + namespaceArr.splice(-1, 1); + let namespace = namespaceArr.join(' / '); + + if (namespaceArr.length > 2) { + namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; + } + + return namespace; + }, }, }; </script> @@ -87,9 +108,7 @@ export default { <div class="project-namespace" :title="namespace" - > - {{namespace}} - </div> + >{{truncatedNamespace}}</div> </div> </a> </li> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list.vue b/app/assets/javascripts/repo/components/commit_sidebar/list.vue new file mode 100644 index 00000000000..fb862e7bf01 --- /dev/null +++ b/app/assets/javascripts/repo/components/commit_sidebar/list.vue @@ -0,0 +1,89 @@ +<script> + import icon from '../../../vue_shared/components/icon.vue'; + import listItem from './list_item.vue'; + import listCollapsed from './list_collapsed.vue'; + + export default { + components: { + icon, + listItem, + listCollapsed, + }, + props: { + title: { + type: String, + required: true, + }, + fileList: { + type: Array, + required: true, + }, + collapsed: { + type: Boolean, + required: true, + }, + }, + methods: { + toggleCollapsed() { + this.$emit('toggleCollapsed'); + }, + }, + }; +</script> + +<template> + <div class="multi-file-commit-panel-section"> + <header + class="multi-file-commit-panel-header" + :class="{ + 'is-collapsed': collapsed, + }" + > + <icon + name="list-bulleted" + :size="18" + css-classes="append-right-default" + /> + <template v-if="!collapsed"> + {{ title }} + <button + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + @click="toggleCollapsed" + > + <i + aria-hidden="true" + class="fa fa-angle-double-right" + > + </i> + </button> + </template> + </header> + <div class="multi-file-commit-list"> + <list-collapsed + v-if="collapsed" + /> + <template v-else> + <ul + v-if="fileList.length" + class="list-unstyled append-bottom-0" + > + <li + v-for="file in fileList" + :key="file.key" + > + <list-item + :file="file" + /> + </li> + </ul> + <div + v-else + class="help-block prepend-top-0" + > + No changes + </div> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue new file mode 100644 index 00000000000..6a0262f271b --- /dev/null +++ b/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue @@ -0,0 +1,35 @@ +<script> + import { mapGetters } from 'vuex'; + import icon from '../../../vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + computed: { + ...mapGetters([ + 'addedFiles', + 'modifiedFiles', + ]), + }, + }; +</script> + +<template> + <div + class="multi-file-commit-list-collapsed text-center" + > + <icon + name="file-addition" + :size="18" + css-classes="multi-file-addition append-bottom-10" + /> + {{ addedFiles.length }} + <icon + name="file-modified" + :size="18" + css-classes="multi-file-modified prepend-top-10 append-bottom-10" + /> + {{ modifiedFiles.length }} + </div> +</template> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue new file mode 100644 index 00000000000..742f746e02f --- /dev/null +++ b/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue @@ -0,0 +1,36 @@ +<script> + import icon from '../../../vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + iconName() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; + }, + iconClass() { + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; + }, + }, + }; +</script> + +<template> + <div class="multi-file-commit-list-item"> + <icon + :name="iconName" + :size="16" + :css-classes="iconClass" + /> + <span class="multi-file-commit-list-path"> + {{ file.path }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index 98117802016..a00e1e9d809 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -40,20 +40,24 @@ export default { </script> <template> - <div class="repository-view"> - <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}"> - <repo-sidebar/> - <div - v-if="isCollapsed" - class="panel-right" - > - <repo-tabs/> - <component - :is="currentBlobView" - /> - <repo-file-buttons/> - </div> + <div + class="multi-file" + :class="{ + 'is-collapsed': isCollapsed + }" + > + <repo-sidebar/> + <div + v-if="isCollapsed" + class="multi-file-edit-pane" + > + <repo-tabs /> + <component + class="multi-file-edit-pane-content" + :is="currentBlobView" + /> + <repo-file-buttons /> </div> - <repo-commit-section v-if="changedFiles.length" /> + <repo-commit-section /> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index 377e3d65348..d3344d0c8dc 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -1,11 +1,18 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; +import tooltip from '../../vue_shared/directives/tooltip'; +import icon from '../../vue_shared/components/icon.vue'; import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; -import { n__ } from '../../locale'; +import commitFilesList from './commit_sidebar/list.vue'; export default { components: { PopupDialog, + icon, + commitFilesList, + }, + directives: { + tooltip, }, data() { return { @@ -13,6 +20,7 @@ export default { submitCommitsLoading: false, startNewMR: false, commitMessage: '', + collapsed: true, }; }, computed: { @@ -23,10 +31,10 @@ export default { 'changedFiles', ]), commitButtonDisabled() { - return !this.commitMessage || this.submitCommitsLoading; + return this.commitMessage === '' || this.submitCommitsLoading || !this.changedFiles.length; }, - commitButtonText() { - return n__('Commit %d file', 'Commit %d files', this.changedFiles.length); + commitMessageCount() { + return this.commitMessage.length; }, }, methods: { @@ -77,12 +85,20 @@ export default { this.submitCommitsLoading = false; }); }, + toggleCollapsed() { + this.collapsed = !this.collapsed; + }, }, }; </script> <template> -<div id="commit-area"> +<div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': collapsed, + }" +> <popup-dialog v-if="showNewBranchDialog" :primary-button-label="__('Create new branch')" @@ -92,78 +108,71 @@ export default { @toggle="showNewBranchDialog = false" @submit="makeCommit(true)" /> + <button + v-if="collapsed" + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn is-collapsed prepend-top-10 append-bottom-10" + @click="toggleCollapsed" + > + <i + aria-hidden="true" + class="fa fa-angle-double-left" + > + </i> + </button> + <commit-files-list + title="Staged" + :file-list="changedFiles" + :collapsed="collapsed" + @toggleCollapsed="toggleCollapsed" + /> <form - class="form-horizontal" - @submit.prevent="tryCommit()"> - <fieldset> - <div class="form-group"> - <label class="col-md-4 control-label staged-files"> - Staged files ({{changedFiles.length}}) - </label> - <div class="col-md-6"> - <ul class="list-unstyled changed-files"> - <li - v-for="(file, index) in changedFiles" - :key="index"> - <span class="help-block"> - {{ file.path }} - </span> - </li> - </ul> - </div> - </div> - <div class="form-group"> - <label - class="col-md-4 control-label" - for="commit-message"> - Commit message - </label> - <div class="col-md-6"> - <textarea - id="commit-message" - class="form-control" - name="commit-message" - v-model="commitMessage"> - </textarea> - </div> - </div> - <div class="form-group target-branch"> - <label - class="col-md-4 control-label" - for="target-branch"> - Target branch - </label> - <div class="col-md-6"> - <span class="help-block"> - {{currentBranch}} - </span> - </div> - </div> - <div class="col-md-offset-4 col-md-6"> - <button - type="submit" - :disabled="commitButtonDisabled" - class="btn btn-success"> - <i - v-if="submitCommitsLoading" - class="js-commit-loading-icon fa fa-spinner fa-spin" - aria-hidden="true" - aria-label="loading"> - </i> - <span class="commit-summary"> - {{ commitButtonText }} - </span> - </button> - </div> - <div class="col-md-offset-4 col-md-6"> - <div class="checkbox"> - <label> - <input type="checkbox" v-model="startNewMR"> - <span>Start a <strong>new merge request</strong> with these changes</span> - </label> - </div> + class="form-horizontal multi-file-commit-form" + @submit.prevent="tryCommit" + v-if="!collapsed" + > + <div class="multi-file-commit-fieldset"> + <textarea + class="form-control multi-file-commit-message" + name="commit-message" + v-model="commitMessage" + placeholder="Commit message" + > + </textarea> + </div> + <div class="multi-file-commit-fieldset"> + <label + v-tooltip + title="Create a new merge request with these changes" + data-container="body" + data-placement="top" + > + <input + type="checkbox" + v-model="startNewMR" + /> + Merge Request + </label> + <button + type="submit" + :disabled="commitButtonDisabled" + class="btn btn-default btn-sm append-right-10 prepend-left-10" + > + <i + v-if="submitCommitsLoading" + class="js-commit-loading-icon fa fa-spinner fa-spin" + aria-hidden="true" + aria-label="loading" + > + </i> + Commit + </button> + <div + class="multi-file-commit-message-count" + > + {{ commitMessageCount }} </div> - </fieldset> + </div> </form> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index 1c864b176b1..f37cbd1e961 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -3,19 +3,18 @@ import { mapGetters, mapActions } from 'vuex'; import flash from '../../flash'; import monacoLoader from '../monaco_loader'; +import Editor from '../lib/editor'; export default { - destroyed() { - if (this.monacoInstance) { - this.monacoInstance.destroy(); - } + beforeDestroy() { + this.editor.dispose(); }, mounted() { - if (this.monaco) { + if (this.editor && monaco) { this.initMonaco(); } else { monacoLoader(['vs/editor/editor.main'], () => { - this.monaco = monaco; + this.editor = Editor.create(monaco); this.initMonaco(); }); @@ -29,47 +28,25 @@ export default { initMonaco() { if (this.shouldHideEditor) return; - if (this.monacoInstance) { - this.monacoInstance.setModel(null); - } + this.editor.clearEditor(); this.getRawFileData(this.activeFile) .then(() => { - if (!this.monacoInstance) { - this.monacoInstance = this.monaco.editor.create(this.$el, { - model: null, - readOnly: false, - contextmenu: true, - scrollBeyondLastLine: false, - }); - - this.languages = this.monaco.languages.getLanguages(); - - this.addMonacoEvents(); - } - - this.setupEditor(); + this.editor.createInstance(this.$refs.editor); }) + .then(() => this.setupEditor()) .catch(() => flash('Error setting up monaco. Please try again.')); }, setupEditor() { if (!this.activeFile) return; - const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw; - const foundLang = this.languages.find(lang => - lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0, - ); - const newModel = this.monaco.editor.createModel( - content, foundLang ? foundLang.id : 'plaintext', - ); + const model = this.editor.createModel(this.activeFile); - this.monacoInstance.setModel(newModel); - }, - addMonacoEvents() { - this.monacoInstance.onKeyUp(() => { + this.editor.attachModel(model); + model.onChange((m) => { this.changeFileContent({ file: this.activeFile, - content: this.monacoInstance.getValue(), + content: m.getValue(), }); }); }, @@ -99,9 +76,14 @@ export default { class="blob-viewer-container blob-editor-container" > <div - v-if="shouldHideEditor" + v-show="shouldHideEditor" v-html="activeFile.html" > </div> + <div + v-show="!shouldHideEditor" + ref="editor" + > + </div> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 5be47d568e7..75787ad6103 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -55,7 +55,7 @@ class="file" @click.prevent="clickedTreeRow(file)"> <td - class="multi-file-table-col-name" + class="multi-file-table-name" :colspan="submoduleColSpan" > <i @@ -85,12 +85,11 @@ </td> <template v-if="!isCollapsed && !isSubmodule"> - <td class="hidden-sm hidden-xs"> + <td class="multi-file-table-col-commit-message hidden-sm hidden-xs"> <a v-if="file.lastCommit.message" @click.stop :href="file.lastCommit.url" - class="commit-message" > {{ file.lastCommit.message }} </a> diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index dd948ee84fb..34f0d51819a 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -22,12 +22,12 @@ export default { <template> <div v-if="showButtons" - class="repo-file-buttons" + class="multi-file-editor-btn-group" > <a :href="activeFile.rawPath" target="_blank" - class="btn btn-default raw" + class="btn btn-default btn-sm raw" rel="noopener noreferrer"> {{ rawDownloadButtonLabel }} </a> @@ -38,17 +38,17 @@ export default { aria-label="File actions"> <a :href="activeFile.blamePath" - class="btn btn-default blame"> + class="btn btn-default btn-sm blame"> Blame </a> <a :href="activeFile.commitsPath" - class="btn btn-default history"> + class="btn btn-default btn-sm history"> History </a> <a :href="activeFile.permalink" - class="btn btn-default permalink"> + class="btn btn-default btn-sm permalink"> Permalink </a> </div> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index d1883299bd9..6ce9267f598 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -32,10 +32,12 @@ export default { </script> <template> -<div class="blob-viewer-container"> +<div> <div v-if="!activeFile.renderError" - v-html="activeFile.html"> + v-html="activeFile.html" + class="multi-file-preview-holder" + > </div> <div v-else-if="activeFile.tempFile" diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 9365b09326f..4ea21913129 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -44,20 +44,16 @@ export default { </script> <template> -<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}"> +<div class="ide-file-list"> <table class="table"> <thead> <tr> <th v-if="isCollapsed" - class="repo-file-options title" > - <strong class="clgray"> - {{ projectName }} - </strong> </th> <template v-else> - <th class="name multi-file-table-col-name"> + <th class="name multi-file-table-name"> Name </th> <th class="hidden-sm hidden-xs last-commit"> @@ -79,7 +75,7 @@ export default { :key="n" /> <repo-file - v-for="(file, index) in treeList" + v-for="file in treeList" :key="file.key" :file="file" /> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index da0714c368c..fb29a60df66 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -36,27 +36,32 @@ export default { <template> <li - :class="{ active : tab.active }" @click="setFileActive(tab)" > <button type="button" - class="close-btn" + class="multi-file-tab-close" @click.stop.prevent="closeFile({ file: tab })" - :aria-label="closeLabel"> + :aria-label="closeLabel" + :class="{ + 'modified': tab.changed, + }" + :disabled="tab.changed" + > <i class="fa" :class="changedClass" - aria-hidden="true"> + aria-hidden="true" + > </i> </button> - <a - href="#" - class="repo-tab" + <div + class="multi-file-tab" + :class="{active : tab.active }" :title="tab.url" - @click.prevent.stop="setFileActive(tab)"> - {{tab.name}} - </a> + > + {{ tab.name }} + </div> </li> </template> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue index 59beae53e8d..ab0bef4f0ac 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -16,14 +16,12 @@ <template> <ul - id="tabs" - class="list-unstyled" + class="multi-file-tabs list-unstyled append-bottom-0" > <repo-tab v-for="tab in openFiles" :key="tab.id" :tab="tab" /> - <li class="tabs-divider" /> </ul> </template> diff --git a/app/assets/javascripts/repo/lib/common/disposable.js b/app/assets/javascripts/repo/lib/common/disposable.js new file mode 100644 index 00000000000..84b29bdb600 --- /dev/null +++ b/app/assets/javascripts/repo/lib/common/disposable.js @@ -0,0 +1,14 @@ +export default class Disposable { + constructor() { + this.disposers = new Set(); + } + + add(...disposers) { + disposers.forEach(disposer => this.disposers.add(disposer)); + } + + dispose() { + this.disposers.forEach(disposer => disposer.dispose()); + this.disposers.clear(); + } +} diff --git a/app/assets/javascripts/repo/lib/common/model.js b/app/assets/javascripts/repo/lib/common/model.js new file mode 100644 index 00000000000..23c4811e6c0 --- /dev/null +++ b/app/assets/javascripts/repo/lib/common/model.js @@ -0,0 +1,56 @@ +/* global monaco */ +import Disposable from './disposable'; + +export default class Model { + constructor(monaco, file) { + this.monaco = monaco; + this.disposable = new Disposable(); + this.file = file; + this.content = file.content !== '' ? file.content : file.raw; + + this.disposable.add( + this.originalModel = this.monaco.editor.createModel( + this.file.raw, + undefined, + new this.monaco.Uri(null, null, `original/${this.file.path}`), + ), + this.model = this.monaco.editor.createModel( + this.content, + undefined, + new this.monaco.Uri(null, null, this.file.path), + ), + ); + + this.events = new Map(); + } + + get url() { + return this.model.uri.toString(); + } + + get path() { + return this.file.path; + } + + getModel() { + return this.model; + } + + getOriginalModel() { + return this.originalModel; + } + + onChange(cb) { + this.events.set( + this.path, + this.disposable.add( + this.model.onDidChangeContent(e => cb(this.model, e)), + ), + ); + } + + dispose() { + this.disposable.dispose(); + this.events.clear(); + } +} diff --git a/app/assets/javascripts/repo/lib/common/model_manager.js b/app/assets/javascripts/repo/lib/common/model_manager.js new file mode 100644 index 00000000000..fd462252795 --- /dev/null +++ b/app/assets/javascripts/repo/lib/common/model_manager.js @@ -0,0 +1,32 @@ +import Disposable from './disposable'; +import Model from './model'; + +export default class ModelManager { + constructor(monaco) { + this.monaco = monaco; + this.disposable = new Disposable(); + this.models = new Map(); + } + + hasCachedModel(path) { + return this.models.has(path); + } + + addModel(file) { + if (this.hasCachedModel(file.path)) { + return this.models.get(file.path); + } + + const model = new Model(this.monaco, file); + this.models.set(model.path, model); + this.disposable.add(model); + + return model; + } + + dispose() { + // dispose of all the models + this.disposable.dispose(); + this.models.clear(); + } +} diff --git a/app/assets/javascripts/repo/lib/decorations/controller.js b/app/assets/javascripts/repo/lib/decorations/controller.js new file mode 100644 index 00000000000..0954b7973c4 --- /dev/null +++ b/app/assets/javascripts/repo/lib/decorations/controller.js @@ -0,0 +1,43 @@ +export default class DecorationsController { + constructor(editor) { + this.editor = editor; + this.decorations = new Map(); + this.editorDecorations = new Map(); + } + + getAllDecorationsForModel(model) { + if (!this.decorations.has(model.url)) return []; + + const modelDecorations = this.decorations.get(model.url); + const decorations = []; + + modelDecorations.forEach(val => decorations.push(...val)); + + return decorations; + } + + addDecorations(model, decorationsKey, decorations) { + const decorationMap = this.decorations.get(model.url) || new Map(); + + decorationMap.set(decorationsKey, decorations); + + this.decorations.set(model.url, decorationMap); + + this.decorate(model); + } + + decorate(model) { + const decorations = this.getAllDecorationsForModel(model); + const oldDecorations = this.editorDecorations.get(model.url) || []; + + this.editorDecorations.set( + model.url, + this.editor.instance.deltaDecorations(oldDecorations, decorations), + ); + } + + dispose() { + this.decorations.clear(); + this.editorDecorations.clear(); + } +} diff --git a/app/assets/javascripts/repo/lib/diff/controller.js b/app/assets/javascripts/repo/lib/diff/controller.js new file mode 100644 index 00000000000..dc0b1c95e59 --- /dev/null +++ b/app/assets/javascripts/repo/lib/diff/controller.js @@ -0,0 +1,71 @@ +/* global monaco */ +import { throttle } from 'underscore'; +import DirtyDiffWorker from './diff_worker'; +import Disposable from '../common/disposable'; + +export const getDiffChangeType = (change) => { + if (change.modified) { + return 'modified'; + } else if (change.added) { + return 'added'; + } else if (change.removed) { + return 'removed'; + } + + return ''; +}; + +export const getDecorator = change => ({ + range: new monaco.Range( + change.lineNumber, + 1, + change.endLineNumber, + 1, + ), + options: { + isWholeLine: true, + linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, + }, +}); + +export default class DirtyDiffController { + constructor(modelManager, decorationsController) { + this.disposable = new Disposable(); + this.editorSimpleWorker = null; + this.modelManager = modelManager; + this.decorationsController = decorationsController; + this.dirtyDiffWorker = new DirtyDiffWorker(); + this.throttledComputeDiff = throttle(this.computeDiff, 250); + this.decorate = this.decorate.bind(this); + + this.dirtyDiffWorker.addEventListener('message', this.decorate); + } + + attachModel(model) { + model.onChange(() => this.throttledComputeDiff(model)); + } + + computeDiff(model) { + this.dirtyDiffWorker.postMessage({ + path: model.path, + originalContent: model.getOriginalModel().getValue(), + newContent: model.getModel().getValue(), + }); + } + + reDecorate(model) { + this.decorationsController.decorate(model); + } + + decorate({ data }) { + const decorations = data.changes.map(change => getDecorator(change)); + this.decorationsController.addDecorations(data.path, 'dirtyDiff', decorations); + } + + dispose() { + this.disposable.dispose(); + + this.dirtyDiffWorker.removeEventListener('message', this.decorate); + this.dirtyDiffWorker.terminate(); + } +} diff --git a/app/assets/javascripts/repo/lib/diff/diff.js b/app/assets/javascripts/repo/lib/diff/diff.js new file mode 100644 index 00000000000..0e37f5c4704 --- /dev/null +++ b/app/assets/javascripts/repo/lib/diff/diff.js @@ -0,0 +1,30 @@ +import { diffLines } from 'diff'; + +// eslint-disable-next-line import/prefer-default-export +export const computeDiff = (originalContent, newContent) => { + const changes = diffLines(originalContent, newContent); + + let lineNumber = 1; + return changes.reduce((acc, change) => { + const findOnLine = acc.find(c => c.lineNumber === lineNumber); + + if (findOnLine) { + Object.assign(findOnLine, change, { + modified: true, + endLineNumber: (lineNumber + change.count) - 1, + }); + } else if ('added' in change || 'removed' in change) { + acc.push(Object.assign({}, change, { + lineNumber, + modified: undefined, + endLineNumber: (lineNumber + change.count) - 1, + })); + } + + if (!change.removed) { + lineNumber += change.count; + } + + return acc; + }, []); +}; diff --git a/app/assets/javascripts/repo/lib/diff/diff_worker.js b/app/assets/javascripts/repo/lib/diff/diff_worker.js new file mode 100644 index 00000000000..e74c4046330 --- /dev/null +++ b/app/assets/javascripts/repo/lib/diff/diff_worker.js @@ -0,0 +1,10 @@ +import { computeDiff } from './diff'; + +self.addEventListener('message', (e) => { + const data = e.data; + + self.postMessage({ + path: data.path, + changes: computeDiff(data.originalContent, data.newContent), + }); +}); diff --git a/app/assets/javascripts/repo/lib/editor.js b/app/assets/javascripts/repo/lib/editor.js new file mode 100644 index 00000000000..db499444402 --- /dev/null +++ b/app/assets/javascripts/repo/lib/editor.js @@ -0,0 +1,79 @@ +import DecorationsController from './decorations/controller'; +import DirtyDiffController from './diff/controller'; +import Disposable from './common/disposable'; +import ModelManager from './common/model_manager'; +import editorOptions from './editor_options'; + +export default class Editor { + static create(monaco) { + this.editorInstance = new Editor(monaco); + + return this.editorInstance; + } + + constructor(monaco) { + this.monaco = monaco; + this.currentModel = null; + this.instance = null; + this.dirtyDiffController = null; + this.disposable = new Disposable(); + + this.disposable.add( + this.modelManager = new ModelManager(this.monaco), + this.decorationsController = new DecorationsController(this), + ); + } + + createInstance(domElement) { + if (!this.instance) { + this.disposable.add( + this.instance = this.monaco.editor.create(domElement, { + model: null, + readOnly: false, + contextmenu: true, + scrollBeyondLastLine: false, + }), + this.dirtyDiffController = new DirtyDiffController( + this.modelManager, this.decorationsController, + ), + ); + } + } + + createModel(file) { + return this.modelManager.addModel(file); + } + + attachModel(model) { + this.instance.setModel(model.getModel()); + this.dirtyDiffController.attachModel(model); + + this.currentModel = model; + + this.instance.updateOptions(editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach((key) => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {})); + + this.dirtyDiffController.reDecorate(model); + } + + clearEditor() { + if (this.instance) { + this.instance.setModel(null); + } + } + + dispose() { + this.disposable.dispose(); + + // dispose main monaco instance + if (this.instance) { + this.instance = null; + } + } +} diff --git a/app/assets/javascripts/repo/lib/editor_options.js b/app/assets/javascripts/repo/lib/editor_options.js new file mode 100644 index 00000000000..701affc466e --- /dev/null +++ b/app/assets/javascripts/repo/lib/editor_options.js @@ -0,0 +1,2 @@ +export default [{ +}]; diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/repo/services/index.js index 2fb45dcb03c..994d325e991 100644 --- a/app/assets/javascripts/repo/services/index.js +++ b/app/assets/javascripts/repo/services/index.js @@ -16,6 +16,10 @@ export default { return Promise.resolve(file.content); } + if (file.raw) { + return Promise.resolve(file.raw); + } + return Vue.http.get(file.rawPath, { params: { format: 'json' } }) .then(res => res.text()); }, diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js index 1ed05ac6e35..5ce9f449905 100644 --- a/app/assets/javascripts/repo/stores/getters.js +++ b/app/assets/javascripts/repo/stores/getters.js @@ -34,3 +34,7 @@ export const canEditFile = (state) => { openedFiles.length && (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); }; + +export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); + +export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 8f116233e72..4216660da8c 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -12,6 +12,9 @@ /> */ + // only allow classes in images.scss e.g. s12 + const validSizes = [8, 12, 16, 18, 24, 32, 48, 72]; + export default { props: { name: { @@ -22,7 +25,10 @@ size: { type: Number, required: false, - default: 0, + default: 16, + validator(value) { + return validSizes.includes(value); + }, }, cssClasses: { @@ -42,10 +48,11 @@ }, }; </script> + <template> <svg :class="[iconSizeClass, cssClasses]"> - <use + <use v-bind="{'xlink:href':spriteHref}"/> </svg> </template> diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 374988bb590..728f9a27aca 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -125,7 +125,7 @@ @include transition(border-color); } -.note-action-button .link-highlight, +.note-action-button, .toolbar-btn, .dropdown-toggle-caret { @include transition(color); diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index dfa3d4c6fb9..cdc2aa196dd 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -88,17 +88,6 @@ border-color: $border-dark; color: $color; } - - svg { - - path { - fill: $color; - } - - use { - stroke: $color; - } - } } @mixin btn-green { @@ -142,6 +131,13 @@ } } +@mixin btn-svg { + height: $gl-padding; + width: $gl-padding; + top: 0; + vertical-align: text-top; +} + .btn { @include btn-default; @include btn-white; @@ -440,3 +436,7 @@ text-decoration: none; } } + +.btn-svg svg { + @include btn-svg; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 5e4ddf366ef..cb1aad90a9c 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -2,14 +2,43 @@ .cgray { color: $common-gray; } .clgray { color: $common-gray-light; } .cred { color: $common-red; } -svg.cred { fill: $common-red; } .cgreen { color: $common-green; } -svg.cgreen { fill: $common-green; } .cdark { color: $common-gray-dark; } + +.text-plain, +.text-plain:hover { + color: $gl-text-color; +} + .text-secondary { color: $gl-text-color-secondary; } +.text-primary, +.text-primary:hover { + color: $brand-primary; +} + +.text-success, +.text-success:hover { + color: $brand-success; +} + +.text-danger, +.text-danger:hover { + color: $brand-danger; +} + +.text-warning, +.text-warning:hover { + color: $brand-warning; +} + +.text-info, +.text-info:hover { + color: $brand-info; +} + .underlined-link { text-decoration: underline; } .hint { font-style: italic; color: $hint-color; } .light { color: $common-gray; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 579bd48fac6..30d5d7a653b 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -1002,6 +1002,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { max-width: 250px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } &:hover { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index e1b086ebb2b..88ce119ee3a 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -34,8 +34,15 @@ } } + .flash-success { + @extend .alert; + @extend .alert-success; + margin: 0; + } + .flash-notice, - .flash-alert { + .flash-alert, + .flash-success { border-radius: $border-radius-default; .container-fluid, @@ -48,7 +55,8 @@ margin-bottom: 0; .flash-notice, - .flash-alert { + .flash-alert, + .flash-success { border-radius: 0; } } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 1ab5e6a93f9..e2084e8f85f 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,35 +1,49 @@ .ci-status-icon-success, .ci-status-icon-passed { - color: $green-500; + svg { + fill: $green-500; + } } .ci-status-icon-failed { - color: $gl-danger; + svg { + fill: $gl-danger; + } } .ci-status-icon-pending, .ci-status-icon-failed_with_warnings, .ci-status-icon-success_with_warnings { - color: $orange-500; + svg { + fill: $orange-500; + } } .ci-status-icon-running { - color: $blue-400; + svg { + fill: $blue-400; + } } .ci-status-icon-canceled, .ci-status-icon-disabled, .ci-status-icon-not-found { - color: $gl-text-color; + svg { + fill: $gl-text-color; + } } .ci-status-icon-created, .ci-status-icon-skipped { - color: $gray-darkest; + svg { + fill: $gray-darkest; + } } .ci-status-icon-manual { - color: $gl-text-color; + svg { + fill: $gl-text-color; + } } .icon-link { diff --git a/app/assets/stylesheets/framework/new-nav.scss b/app/assets/stylesheets/framework/new-nav.scss deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/assets/stylesheets/framework/new-nav.scss +++ /dev/null diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index bb70b270299..dbee7073975 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -134,19 +134,22 @@ } .select2-search { - padding: 15px 15px 5px; + padding: $grid-size; .select2-drop-auto-width & { - padding: 15px 15px 5px; + padding: $grid-size; } input { - padding: 2px 25px 2px 5px; + padding: $grid-size; background: $white-light image-url('select2.png'); + background-clip: content-box; + background-origin: content-box; background-repeat: no-repeat; - background-position: right 0 bottom 6px; + background-position: right 0 bottom 0 !important; border: 1px solid $input-border; border-radius: $border-radius-default; + line-height: 16px; transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; &:focus { @@ -156,11 +159,16 @@ &.select2-active { background-color: $white-light; background-image: image-url('select2-spinner.gif') !important; + background-origin: content-box; background-repeat: no-repeat; - background-position: right 5px center !important; + background-position: right 6px center !important; background-size: 16px 16px !important; } } + + + .select2-results { + padding-top: 0; + } } .select2-results { diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss index d5c6ddbb4a5..1c6e2bf3074 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap.scss @@ -195,33 +195,6 @@ summary { } } -// Typography ================================================================= - -.text-primary, -.text-primary:hover { - color: $brand-primary; -} - -.text-success, -.text-success:hover { - color: $brand-success; -} - -.text-danger, -.text-danger:hover { - color: $brand-danger; -} - -.text-warning, -.text-warning:hover { - color: $brand-warning; -} - -.text-info, -.text-info:hover { - color: $brand-info; -} - // Prevent datetimes on tooltips to break into two lines .local-timeago { white-space: nowrap; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7131c18ce3d..63c51747f92 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -70,14 +70,13 @@ .title { padding: 0; - margin-bottom: 16px; + margin-bottom: $gl-padding; border-bottom: 0; } .btn-edit { margin-left: auto; - // Set height to match title height - height: 2em; + height: $gl-padding * 2; } // Border around images in issue and MR descriptions. diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8bb68ad2425..d3dda2e7d25 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -203,7 +203,24 @@ ul.related-merge-requests > li { } .create-mr-dropdown-wrap { - @include new-style-dropdown; + .branch-message, + .ref-message { + display: none; + } + + .ref::selection { + color: $placeholder-text-color; + } + + .dropdown { + .dropdown-menu-toggle { + min-width: 285px; + } + + .dropdown-select { + width: 285px; + } + } .btn-group:not(.hide) { display: flex; @@ -214,15 +231,16 @@ ul.related-merge-requests > li { flex-shrink: 0; } - .dropdown-menu { + .create-merge-request-dropdown-menu { width: 300px; opacity: 1; visibility: visible; transform: translateY(0); display: none; + margin-top: 4px; } - .dropdown-toggle { + .create-merge-request-dropdown-toggle { .fa-caret-down { pointer-events: none; color: inherit; @@ -230,18 +248,50 @@ ul.related-merge-requests > li { } } + .droplab-item-ignore { + pointer-events: auto; + } + + .create-item { + cursor: pointer; + margin: 0 1px; + + &:hover, + &:focus { + background-color: $dropdown-item-hover-bg; + color: $gl-text-color; + } + } + + li.divider { + margin: 8px 10px; + } + li:not(.divider) { + padding: 8px 9px; + + &:last-child { + padding-bottom: 8px; + } + &.droplab-item-selected { .icon-container { i { visibility: visible; } } + + .description { + display: block; + } + } + + &.droplab-item-ignore { + padding-top: 8px; } .icon-container { float: left; - padding-left: 6px; i { visibility: hidden; @@ -249,13 +299,12 @@ ul.related-merge-requests > li { } .description { - padding-left: 30px; - font-size: 13px; + padding-left: 22px; + } - strong { - display: block; - font-weight: $gl-font-weight-bold; - } + input, + span { + margin: 4px 0 0; } } } diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index b7985c4dea5..b2250a1ce2f 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -252,6 +252,10 @@ background: $white-light; } + .login-page-broadcast { + margin-top: 50px; + } + .navless-container { padding: 65px 15px; // height of footer + bottom padding of email confirmation link diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 2461b818219..4fe182c9fce 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -543,10 +543,7 @@ ul.notes { } svg { - height: 16px; - width: 16px; - top: 0; - vertical-align: text-top; + @include btn-svg; } .award-control-icon-positive, @@ -780,12 +777,6 @@ ul.notes { } } - svg { - fill: currentColor; - height: 16px; - width: 16px; - } - .loading { margin: 0; height: auto; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index aaad6dbba8e..2dc0c288a6d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -291,14 +291,7 @@ } svg { - - path { - fill: $layout-link-gray; - } - - use { - stroke: $layout-link-gray; - } + fill: $layout-link-gray; } .fa-caret-down { @@ -402,6 +395,18 @@ } } } + + .clone-dropdown-btn { + background-color: $white-light; + } + + .clone-options-dropdown { + min-width: 240px; + + .dropdown-menu-inner-content { + min-width: 320px; + } + } } .project-repo-buttons { @@ -886,10 +891,6 @@ pre.light-well { font-size: $gl-font-size; } - a { - color: $gl-text-color; - } - .avatar-container, .controls { flex: 0 0 auto; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index d93c51d5448..402412eae71 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -35,270 +35,276 @@ } } -.repository-view { - border: 1px solid $border-color; - border-radius: $border-radius-default; - color: $almost-black; +.multi-file { + display: flex; + height: calc(100vh - 145px); + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + + &.is-collapsed { + .ide-file-list { + max-width: 250px; + } + } +} - .code.white pre .hll { - background-color: $well-light-border !important; +.ide-file-list { + flex: 1; + overflow: scroll; + + .file { + cursor: pointer; } - .tree-content-holder { - display: -webkit-flex; - display: flex; - min-height: 300px; + a { + color: $gl-text-color; } - .tree-content-holder-mini { - height: 100vh; + th { + position: sticky; + top: 0; } +} - .panel-right { - display: -webkit-flex; - display: flex; - -webkit-flex-direction: column; - flex-direction: column; - width: 80%; - height: 100%; +.multi-file-table-name, +.multi-file-table-col-commit-message { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; +} - .monaco-editor.vs { - .current-line { - border: 0; - background: $well-light-border; - } +.multi-file-table-name { + width: 350px; +} - .line-numbers { - cursor: pointer; +.multi-file-table-col-commit-message { + width: 50%; +} - &:hover { - text-decoration: underline; - } - } - } +.multi-file-edit-pane { + display: flex; + flex-direction: column; + flex: 1; + border-left: 1px solid $white-dark; + overflow: hidden; +} - .blob-no-preview { - .vertical-center { - justify-content: center; - width: 100%; - } - } +.multi-file-tabs { + display: flex; + overflow: scroll; + background-color: $white-normal; + box-shadow: inset 0 -1px $white-dark; - &.blob-editor-container { - overflow: hidden; - } + > li { + position: relative; + } +} - .blob-viewer-container { - -webkit-flex: 1; - flex: 1; - overflow: auto; - - > div, - .file-content:not(.wiki) { - display: flex; - } - - > div, - .file-content, - .blob-viewer, - .line-number, - .blob-content, - .code { - min-height: 100%; - width: 100%; - } - - .line-numbers { - min-width: 44px; - } - - .blob-content { - flex: 1; - overflow-x: auto; - } - } +.multi-file-tab { + @include str-truncated(150px); + padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding; + background-color: $gray-normal; + border-right: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + cursor: pointer; + + &.active { + background-color: $white-light; + border-bottom-color: $white-light; + } +} - #tabs { - position: relative; - flex-shrink: 0; - display: flex; - width: 100%; - padding-left: 0; - margin-bottom: 0; - white-space: nowrap; - overflow-y: hidden; - overflow-x: auto; - - li { - position: relative; - background: $gray-normal; - padding: #{$gl-padding / 2} $gl-padding; - border-right: 1px solid $white-dark; - border-bottom: 1px solid $white-dark; - cursor: pointer; - - &.active { - background: $white-light; - border-bottom: 0; - } - - a { - @include str-truncated(100px); - color: $gl-text-color; - vertical-align: middle; - text-decoration: none; - margin-right: 12px; - - &:focus { - outline: none; - } - } - - .close-btn { - position: absolute; - right: 8px; - top: 50%; - padding: 0; - background: none; - border: 0; - font-size: $gl-font-size; - transform: translateY(-50%); - } - - .close-icon:hover { - color: $hint-color; - } - - .close-icon, - .unsaved-icon { - color: $gray-darkest; - } - - .unsaved-icon { - color: $brand-success; - } - - &.tabs-divider { - width: 100%; - background-color: $white-light; - border-right: 0; - border-top-right-radius: 2px; - } - } - } +.multi-file-tab-close { + position: absolute; + right: 8px; + top: 50%; + padding: 0; + background: none; + border: 0; + font-size: $gl-font-size; + color: $gray-darkest; + transform: translateY(-50%); + + &:not(.modified):hover, + &:not(.modified):focus { + color: $hint-color; + } - .repo-file-buttons { - background-color: $white-light; - padding: 5px 10px; - border-top: 1px solid $white-normal; - } + &.modified { + color: $indigo-700; + } +} - #binary-viewer { - height: 80vh; - overflow: auto; - margin: 0; - - .blob-viewer { - padding-top: 20px; - padding-left: 20px; - } - - .binary-unknown { - text-align: center; - padding-top: 100px; - background: $gray-light; - height: 100%; - font-size: 17px; - - span { - display: block; - } - } - } +.multi-file-edit-pane-content { + flex: 1; + height: 0; +} + +.multi-file-editor-btn-group { + padding: $grid-size; + border-top: 1px solid $white-dark; +} + +// Not great, but this is to deal with our current output +.multi-file-preview-holder { + height: 100%; + overflow: scroll; + + .blob-viewer { + height: 100%; } - #commit-area { - background: $gray-light; - padding: 20px; + .file-content.code { + display: flex; - .help-block { - padding-top: 7px; - margin-top: 0; + i { + margin-left: -10px; } } - #view-toggler { - height: 41px; - position: relative; - display: block; - border-bottom: 1px solid $white-normal; - background: $white-light; - margin-top: -5px; + .line-numbers { + min-width: 50px; } - #binary-viewer { - img { - max-width: 100%; - } + .file-content, + .line-numbers, + .blob-content, + .code { + min-height: 100%; } +} - #sidebar { - flex: 1; - height: 100%; +.multi-file-commit-panel { + display: flex; + flex-direction: column; + height: 100%; + width: 290px; + padding: $gl-padding; + background-color: $gray-light; + border-left: 1px solid $white-dark; + + &.is-collapsed { + width: 60px; + padding: 0; + } +} - &.sidebar-mini { - width: 20%; - border-right: 1px solid $white-normal; - overflow: auto; - } +.multi-file-commit-panel-section { + display: flex; + flex-direction: column; + flex: 1; +} - .table { - margin-bottom: 0; - } +.multi-file-commit-panel-header { + display: flex; + align-items: center; + padding: 0 0 12px; + margin-bottom: 12px; + border-bottom: 1px solid $white-dark; - tr { - .repo-file-options { - padding: 2px 16px; - width: 100%; - } - - .title { - font-size: 10px; - text-transform: uppercase; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: middle; - } - - .file-icon { - margin-right: 5px; - } - - td { - white-space: nowrap; - } - } + &.is-collapsed { + border-bottom: 1px solid $white-dark; - .file { - cursor: pointer; + svg { + margin-left: auto; + margin-right: auto; } + } +} - a { - @include str-truncated(250px); - color: $almost-black; - } +.multi-file-commit-panel-collapse-btn { + padding-top: 0; + padding-bottom: 0; + margin-left: auto; + font-size: 20px; + + &.is-collapsed { + margin-right: auto; + } +} + +.multi-file-commit-list { + flex: 1; + overflow: scroll; +} + +.multi-file-commit-list-item { + display: flex; + align-items: center; +} + +.multi-file-addition { + fill: $green-500; +} + +.multi-file-modified { + fill: $orange-500; +} + +.multi-file-commit-list-collapsed { + display: flex; + flex-direction: column; + + > svg { + margin-left: auto; + margin-right: auto; } } -.render-error { - min-height: calc(100vh - 62px); +.multi-file-commit-list-path { + @include str-truncated(100%); +} + +.multi-file-commit-form { + padding-top: 12px; + border-top: 1px solid $white-dark; +} + +.multi-file-commit-fieldset { + display: flex; + align-items: center; + padding-bottom: 12px; - p { - width: 100%; + .btn { + flex: 1; } } -.multi-file-table-col-name { - width: 350px; +.multi-file-commit-message.form-control { + height: 80px; + resize: none; +} + +.dirty-diff { + // !important need to override monaco inline style + width: 4px !important; + left: 0 !important; + + &-modified { + background-color: $blue-500; + } + + &-added { + background-color: $green-600; + } + + &-removed { + height: 0 !important; + width: 0 !important; + bottom: -2px; + border-style: solid; + border-width: 5px; + border-color: transparent transparent transparent $red-500; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100px; + height: 1px; + background-color: rgba($red-500, .5); + } + } } |