diff options
author | Phil Hughes <me@iamphill.com> | 2017-11-30 09:52:58 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2017-11-30 09:52:58 +0000 |
commit | 386cbf22ee8fc1d206e76f0f34568379ae726e02 (patch) | |
tree | 4976d52023637beaa5046035b64554dd451f0d27 /app/assets | |
parent | 1880809d8ef9b650d1af615ebbaa590626ccf17f (diff) | |
parent | 73e48b745c34c048c56abf28e6c278707d9bd2f3 (diff) | |
download | gitlab-ce-386cbf22ee8fc1d206e76f0f34568379ae726e02.tar.gz |
Merge branch 'master' into multi-file-editor-dirty-diff-indicator
Diffstat (limited to 'app/assets')
43 files changed, 1201 insertions, 612 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/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_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/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/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/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 c45a478f2df..402412eae71 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -35,273 +35,245 @@ } } -.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; - min-width: initial; +.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 { |