diff options
author | Andreas Kämmerle <andreas.kaemmerle@gmail.com> | 2018-07-03 15:30:36 +0200 |
---|---|---|
committer | Andreas Kämmerle <andreas.kaemmerle@gmail.com> | 2018-07-03 15:30:36 +0200 |
commit | e4a310113a3a5784be863151e5bcecacb23aa244 (patch) | |
tree | 79f9019b2e001a192eae3569b5746ba9c4ec9476 /app | |
parent | d505b48806c0880ac810374973c4b9ba802c26e8 (diff) | |
parent | c489d53b2e2eecb22f8dc7034da142221220e89f (diff) | |
download | gitlab-ce-e4a310113a3a5784be863151e5bcecacb23aa244.tar.gz |
Merge branch 'master' of https://gitlab.com/gitlab-org/gitlab-ce into update-template-name-via-sentence-case
# Conflicts:
# .gitlab/issue_templates/Feature proposal.md
Diffstat (limited to 'app')
1190 files changed, 16691 insertions, 7961 deletions
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico Binary files differdeleted file mode 100644 index 4af3582b60d..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_created.ico b/app/assets/images/ci_favicons/dev/favicon_status_created.ico Binary files differdeleted file mode 100644 index 13639da2e8a..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_created.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_failed.ico b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico Binary files differdeleted file mode 100644 index 5f0e711b104..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_failed.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_manual.ico b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico Binary files differdeleted file mode 100644 index 8b1168a1267..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_manual.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico Binary files differdeleted file mode 100644 index ed19b69e1c5..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_pending.ico b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico Binary files differdeleted file mode 100644 index 5dfefd4cc5a..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_pending.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_running.ico b/app/assets/images/ci_favicons/dev/favicon_status_running.ico Binary files differdeleted file mode 100644 index a41539c0e3e..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_running.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico Binary files differdeleted file mode 100644 index 2c1ae552b93..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_success.ico b/app/assets/images/ci_favicons/dev/favicon_status_success.ico Binary files differdeleted file mode 100644 index 70f0ca61eca..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_success.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/dev/favicon_status_warning.ico b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico Binary files differdeleted file mode 100644 index db289e03eb1..00000000000 --- a/app/assets/images/ci_favicons/dev/favicon_status_warning.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_canceled.ico b/app/assets/images/ci_favicons/favicon_status_canceled.ico Binary files differdeleted file mode 100644 index 23adcffff50..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_canceled.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_canceled.png b/app/assets/images/ci_favicons/favicon_status_canceled.png Binary files differnew file mode 100644 index 00000000000..8adaa9c600b --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_canceled.png diff --git a/app/assets/images/ci_favicons/favicon_status_created.ico b/app/assets/images/ci_favicons/favicon_status_created.ico Binary files differdeleted file mode 100644 index f9d93b390d8..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_created.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_created.png b/app/assets/images/ci_favicons/favicon_status_created.png Binary files differnew file mode 100644 index 00000000000..ca788dd0034 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_created.png diff --git a/app/assets/images/ci_favicons/favicon_status_failed.ico b/app/assets/images/ci_favicons/favicon_status_failed.ico Binary files differdeleted file mode 100644 index 28a22ebf724..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_failed.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_failed.png b/app/assets/images/ci_favicons/favicon_status_failed.png Binary files differnew file mode 100644 index 00000000000..93f1e2772fd --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_failed.png diff --git a/app/assets/images/ci_favicons/favicon_status_manual.ico b/app/assets/images/ci_favicons/favicon_status_manual.ico Binary files differdeleted file mode 100644 index dbbf1abf30c..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_manual.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_manual.png b/app/assets/images/ci_favicons/favicon_status_manual.png Binary files differnew file mode 100644 index 00000000000..c926062c806 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_manual.png diff --git a/app/assets/images/ci_favicons/favicon_status_not_found.ico b/app/assets/images/ci_favicons/favicon_status_not_found.ico Binary files differdeleted file mode 100644 index 49b9b232dd1..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_not_found.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_not_found.png b/app/assets/images/ci_favicons/favicon_status_not_found.png Binary files differnew file mode 100644 index 00000000000..df3049315a9 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_not_found.png diff --git a/app/assets/images/ci_favicons/favicon_status_pending.ico b/app/assets/images/ci_favicons/favicon_status_pending.ico Binary files differdeleted file mode 100644 index 05962f3f148..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_pending.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_pending.png b/app/assets/images/ci_favicons/favicon_status_pending.png Binary files differnew file mode 100644 index 00000000000..f7d67d4a230 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_pending.png diff --git a/app/assets/images/ci_favicons/favicon_status_running.ico b/app/assets/images/ci_favicons/favicon_status_running.ico Binary files differdeleted file mode 100644 index 7fa3d4d48d4..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_running.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_running.png b/app/assets/images/ci_favicons/favicon_status_running.png Binary files differnew file mode 100644 index 00000000000..ff4167c4b20 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_running.png diff --git a/app/assets/images/ci_favicons/favicon_status_skipped.ico b/app/assets/images/ci_favicons/favicon_status_skipped.ico Binary files differdeleted file mode 100644 index b0c26b62068..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_skipped.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_skipped.png b/app/assets/images/ci_favicons/favicon_status_skipped.png Binary files differnew file mode 100644 index 00000000000..a9c36464b69 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_skipped.png diff --git a/app/assets/images/ci_favicons/favicon_status_success.ico b/app/assets/images/ci_favicons/favicon_status_success.ico Binary files differdeleted file mode 100644 index b150960b5be..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_success.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_success.png b/app/assets/images/ci_favicons/favicon_status_success.png Binary files differnew file mode 100644 index 00000000000..bcc30c73f5f --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_success.png diff --git a/app/assets/images/ci_favicons/favicon_status_warning.ico b/app/assets/images/ci_favicons/favicon_status_warning.ico Binary files differdeleted file mode 100644 index 7e71d71684d..00000000000 --- a/app/assets/images/ci_favicons/favicon_status_warning.ico +++ /dev/null diff --git a/app/assets/images/ci_favicons/favicon_status_warning.png b/app/assets/images/ci_favicons/favicon_status_warning.png Binary files differnew file mode 100644 index 00000000000..6db3b0280f5 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_warning.png diff --git a/app/assets/images/favicon-blue.ico b/app/assets/images/favicon-blue.ico Binary files differdeleted file mode 100644 index 156fcf07588..00000000000 --- a/app/assets/images/favicon-blue.ico +++ /dev/null diff --git a/app/assets/images/favicon-blue.png b/app/assets/images/favicon-blue.png Binary files differnew file mode 100644 index 00000000000..2229fe79462 --- /dev/null +++ b/app/assets/images/favicon-blue.png diff --git a/app/assets/images/favicon-yellow.ico b/app/assets/images/favicon-yellow.ico Binary files differdeleted file mode 100644 index b650f277fb6..00000000000 --- a/app/assets/images/favicon-yellow.ico +++ /dev/null diff --git a/app/assets/images/favicon-yellow.png b/app/assets/images/favicon-yellow.png Binary files differnew file mode 100644 index 00000000000..2d5289818b4 --- /dev/null +++ b/app/assets/images/favicon-yellow.png diff --git a/app/assets/images/favicon.ico b/app/assets/images/favicon.ico Binary files differdeleted file mode 100644 index 3479cbbb46f..00000000000 --- a/app/assets/images/favicon.ico +++ /dev/null diff --git a/app/assets/images/favicon.png b/app/assets/images/favicon.png Binary files differnew file mode 100644 index 00000000000..845e0ec34a5 --- /dev/null +++ b/app/assets/images/favicon.png diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index c117d080bda..de4566bb119 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -1,4 +1,4 @@ -/* eslint-disable no-param-reassign, class-methods-use-this */ +/* eslint-disable class-methods-use-this */ import $ from 'jquery'; import Cookies from 'js-cookie'; diff --git a/app/assets/javascripts/ajax_loading_spinner.js b/app/assets/javascripts/ajax_loading_spinner.js index bd08308904c..54e86f329e4 100644 --- a/app/assets/javascripts/ajax_loading_spinner.js +++ b/app/assets/javascripts/ajax_loading_spinner.js @@ -26,7 +26,7 @@ export default class AjaxLoadingSpinner { } static toggleLoadingIcon(iconElement) { - const classList = iconElement.classList; + const { classList } = iconElement; classList.toggle(iconElement.dataset.icon); classList.toggle('fa-spinner'); classList.toggle('fa-spin'); diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 000938e475f..0ca0e8f35dd 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -150,14 +150,15 @@ const Api = { }, // Return group projects list. Filtered by query - groupProjects(groupId, query, callback) { + groupProjects(groupId, query, options, callback) { const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); + const defaults = { + search: query, + per_page: 20, + }; return axios .get(url, { - params: { - search: query, - per_page: 20, - }, + params: Object.assign({}, defaults, options), }) .then(({ data }) => callback(data)); }, @@ -243,6 +244,15 @@ const Api = { }); }, + createBranch(id, { ref, branch }) { + const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + ref, + branch, + }); + }, + buildUrl(url) { let urlRoot = ''; if (gon.relative_url_root != null) { diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 0da872db7e5..fa00a3cf386 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,4 +1,4 @@ -/* eslint-disable no-param-reassign, prefer-template, no-var, no-void, consistent-return */ +/* eslint-disable no-param-reassign, prefer-template, no-void, consistent-return */ import AccessorUtilities from './lib/utils/accessor'; @@ -31,7 +31,9 @@ export default class Autosave { // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 const event = new Event('change', { bubbles: true, cancelable: false }); const field = this.field.get(0); - field.dispatchEvent(event); + if (field) { + field.dispatchEvent(event); + } } save() { diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index eb0f06efab4..70f20c5c7cf 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -11,7 +11,8 @@ import axios from './lib/utils/axios_utils'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; -const requestAnimationFrame = window.requestAnimationFrame || +const requestAnimationFrame = + window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.setTimeout; @@ -37,21 +38,28 @@ class AwardsHandler { this.emoji = emoji; this.eventListeners = []; // If the user shows intent let's pre-build the menu - this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => { - const $menu = $('.emoji-menu'); - if ($menu.length === 0) { - requestAnimationFrame(() => { - this.createEmojiMenu(); - }); - } - }); - this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => { + this.registerEventListener( + 'one', + $(document), + 'mouseenter focus', + '.js-add-award', + 'mouseenter focus', + () => { + const $menu = $('.emoji-menu'); + if ($menu.length === 0) { + requestAnimationFrame(() => { + this.createEmojiMenu(); + }); + } + }, + ); + this.registerEventListener('on', $(document), 'click', '.js-add-award', e => { e.stopPropagation(); e.preventDefault(); this.showEmojiMenu($(e.currentTarget)); }); - this.registerEventListener('on', $('html'), 'click', (e) => { + this.registerEventListener('on', $('html'), 'click', e => { const $target = $(e.target); if (!$target.closest('.emoji-menu').length) { $('.js-awards-block.current').removeClass('current'); @@ -61,12 +69,14 @@ class AwardsHandler { } } }); - this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => { + this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', e => { e.preventDefault(); const $target = $(e.currentTarget); const $glEmojiElement = $target.find('gl-emoji'); const $spriteIconElement = $target.find('.icon'); - const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name'); + const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data( + 'name', + ); $target.closest('.js-awards-block').addClass('current'); this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName); @@ -83,7 +93,10 @@ class AwardsHandler { showEmojiMenu($addBtn) { if ($addBtn.hasClass('js-note-emoji')) { - $addBtn.closest('.note').find('.js-awards-block').addClass('current'); + $addBtn + .closest('.note') + .find('.js-awards-block') + .addClass('current'); } else { $addBtn.closest('.js-awards-block').addClass('current'); } @@ -177,32 +190,38 @@ class AwardsHandler { const remainingCategories = Object.keys(categoryMap).slice(1); const allCategoriesAddedPromise = remainingCategories.reduce( (promiseChain, categoryNameKey) => - promiseChain.then(() => - new Promise((resolve) => { - const emojisInCategory = categoryMap[categoryNameKey]; - const categoryMarkup = this.renderCategory( - categoryLabelMap[categoryNameKey], - emojisInCategory, - ); - requestAnimationFrame(() => { - emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup); - resolve(); - }); - }), - ), + promiseChain.then( + () => + new Promise(resolve => { + const emojisInCategory = categoryMap[categoryNameKey]; + const categoryMarkup = this.renderCategory( + categoryLabelMap[categoryNameKey], + emojisInCategory, + ); + requestAnimationFrame(() => { + emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup); + resolve(); + }); + }), + ), Promise.resolve(), ); - allCategoriesAddedPromise.then(() => { - // Used for tests - // We check for the menu in case it was destroyed in the meantime - if (menu) { - menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish')); - } - }).catch((err) => { - emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>'); - throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`); - }); + allCategoriesAddedPromise + .then(() => { + // Used for tests + // We check for the menu in case it was destroyed in the meantime + if (menu) { + menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish')); + } + }) + .catch(err => { + emojiContentElement.insertAdjacentHTML( + 'beforeend', + '<p>We encountered an error while adding the remaining categories</p>', + ); + throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`); + }); } renderCategory(name, emojiList, opts = {}) { @@ -211,7 +230,9 @@ class AwardsHandler { ${name} </h5> <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}"> - ${emojiList.map(emojiName => ` + ${emojiList + .map( + emojiName => ` <li class="emoji-menu-list-item"> <button class="emoji-menu-btn text-center js-emoji-btn" type="button"> ${this.emoji.glEmojiTag(emojiName, { @@ -219,7 +240,9 @@ class AwardsHandler { })} </button> </li> - `).join('\n')} + `, + ) + .join('\n')} </ul> `; } @@ -232,7 +255,7 @@ class AwardsHandler { top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`, }; if (position === 'right') { - css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`; + css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`; $menu.addClass('is-aligned-right'); } else { css.left = `${$addBtn.offset().left}px`; @@ -416,7 +439,10 @@ class AwardsHandler { </button> `; const $emojiButton = $(buttonHtml); - $emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('name', emojiName); + $emojiButton + .insertBefore(votesBlock.find('.js-award-holder')) + .find('.emoji-icon') + .data('name', emojiName); this.animateEmoji($emojiButton); $('.award-control').tooltip(); votesBlock.removeClass('current'); @@ -426,7 +452,7 @@ class AwardsHandler { const className = 'pulse animated once short'; $emoji.addClass(className); - this.registerEventListener('on', $emoji, animationEndEventString, (e) => { + this.registerEventListener('on', $emoji, animationEndEventString, e => { $(e.currentTarget).removeClass(className); }); } @@ -444,15 +470,16 @@ class AwardsHandler { if (this.isUserAuthored($emojiButton)) { this.userAuthored($emojiButton); } else { - axios.post(awardUrl, { - name: emoji, - }) - .then(({ data }) => { - if (data.ok) { - callback(); - } - }) - .catch(() => flash(__('Something went wrong on our end.'))); + axios + .post(awardUrl, { + name: emoji, + }) + .then(({ data }) => { + if (data.ok) { + callback(); + } + }) + .catch(() => flash(__('Something went wrong on our end.'))); } } @@ -486,26 +513,33 @@ class AwardsHandler { } getFrequentlyUsedEmojis() { - return this.frequentlyUsedEmojis || (() => { - const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(',')); - this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter( - inputName => this.emoji.isEmojiNameValid(inputName), - ); - - return this.frequentlyUsedEmojis; - })(); + return ( + this.frequentlyUsedEmojis || + (() => { + const frequentlyUsedEmojis = _.uniq( + (Cookies.get('frequently_used_emojis') || '').split(','), + ); + this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName => + this.emoji.isEmojiNameValid(inputName), + ); + + return this.frequentlyUsedEmojis; + })() + ); } setupSearch() { const $search = $('.js-emoji-menu-search'); - this.registerEventListener('on', $search, 'input', (e) => { - const term = $(e.target).val().trim(); + this.registerEventListener('on', $search, 'input', e => { + const term = $(e.target) + .val() + .trim(); this.searchEmojis(term); }); const $menu = $('.emoji-menu'); - this.registerEventListener('on', $menu, transitionEndEventString, (e) => { + this.registerEventListener('on', $menu, transitionEndEventString, e => { if (e.target === e.currentTarget) { // Clear the search this.searchEmojis(''); @@ -523,19 +557,26 @@ class AwardsHandler { // Generate a search result block const h5 = $('<h5 class="emoji-search-title"/>').text('Search results'); const foundEmojis = this.findMatchingEmojiElements(term).show(); - const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis); + const ul = $('<ul>') + .addClass('emoji-menu-list emoji-menu-search') + .append(foundEmojis); $('.emoji-menu-content ul, .emoji-menu-content h5').hide(); - $('.emoji-menu-content').append(h5).append(ul); + $('.emoji-menu-content') + .append(h5) + .append(ul); } else { - $('.emoji-menu-content').children().show(); + $('.emoji-menu-content') + .children() + .show(); } } findMatchingEmojiElements(query) { const emojiMatches = this.emoji.filterEmojiNamesByAlias(query); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); - const $matchingElements = $emojiElements - .filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0); + const $matchingElements = $emojiElements.filter( + (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, + ); return $matchingElements.closest('li').clone(); } @@ -550,16 +591,13 @@ class AwardsHandler { $emojiMenu.addClass(IS_RENDERED); // enqueues animation as a microtask, so it begins ASAP once IS_RENDERED added - return Promise.resolve() - .then(() => $emojiMenu.addClass(IS_VISIBLE)); + return Promise.resolve().then(() => $emojiMenu.addClass(IS_VISIBLE)); } hideMenuElement($emojiMenu) { - $emojiMenu.on(transitionEndEventString, (e) => { + $emojiMenu.on(transitionEndEventString, e => { if (e.currentTarget === e.target) { - $emojiMenu - .removeClass(IS_RENDERED) - .off(transitionEndEventString); + $emojiMenu.removeClass(IS_RENDERED).off(transitionEndEventString); } }); @@ -567,7 +605,7 @@ class AwardsHandler { } destroy() { - this.eventListeners.forEach((entry) => { + this.eventListeners.forEach(entry => { entry.element.off.call(entry.element, ...entry.args); }); $('.emoji-menu').remove(); @@ -577,8 +615,9 @@ class AwardsHandler { let awardsHandlerPromise = null; export default function loadAwardsHandler(reload = false) { if (!awardsHandlerPromise || reload) { - awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji') - .then(Emoji => new AwardsHandler(Emoji)); + awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then( + Emoji => new AwardsHandler(Emoji), + ); } return awardsHandlerPromise; } diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index d0f60e1d4cb..b4bfaee1d85 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -72,11 +72,11 @@ export default { rel="noopener noreferrer" > <img - class="project-badge" :src="imageUrlWithRetries" + class="project-badge" + aria-hidden="true" @load="onLoad" @error="onError" - aria-hidden="true" /> </a> @@ -91,9 +91,9 @@ export default { > <div class="btn btn-default btn-sm disabled"> <icon + :size="16" class="prepend-left-8 append-right-8" name="doc_image" - :size="16" aria-hidden="true" /> </div> @@ -105,16 +105,16 @@ export default { </div> <button + v-tooltip v-show="hasError" + :title="s__('Badges|Reload badge image')" class="btn btn-transparent btn-sm text-primary" type="button" - v-tooltip - :title="s__('Badges|Reload badge image')" @click="reloadImage" > <icon - name="retry" :size="16" + name="retry" /> </button> </div> diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 5975cb9669e..7a13f74c570 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -153,10 +153,10 @@ export default { <label for="badge-link-url">{{ s__('Badges|Link') }}</label> <input id="badge-link-url" - type="text" - class="form-control" v-model="linkUrl" :placeholder="$options.badgeLinkUrlPlaceholder" + type="text" + class="form-control" @input="debouncedPreview" /> <span @@ -169,10 +169,10 @@ export default { <label for="badge-image-url">{{ s__('Badges|Badge image URL') }}</label> <input id="badge-image-url" - type="text" - class="form-control" v-model="imageUrl" :placeholder="$options.badgeImageUrlPlaceholder" + type="text" + class="form-control" @input="debouncedPreview" /> <span @@ -184,8 +184,8 @@ export default { <div class="form-group"> <label for="badge-preview">{{ s__('Badges|Badge image preview') }}</label> <badge - id="badge-preview" v-show="renderedBadge && !isRendering" + id="badge-preview" :image-url="renderedImageUrl" :link-url="renderedLinkUrl" /> @@ -202,16 +202,16 @@ export default { <div class="row-content-block"> <loading-button - type="submit" - container-class="btn btn-success" :disabled="!canSubmit" :loading="isSaving" :label="submitButtonLabel" + type="submit" + container-class="btn btn-success" /> <button + v-if="isEditing" class="btn btn-cancel" type="button" - v-if="isEditing" @click="onCancel" >{{ __('Cancel') }}</button> </div> diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue index af062bdf8c6..98aa00af0d7 100644 --- a/app/assets/javascripts/badges/components/badge_list_row.vue +++ b/app/assets/javascripts/badges/components/badge_list_row.vue @@ -41,9 +41,9 @@ export default { <template> <div class="gl-responsive-table-row-layout gl-responsive-table-row"> <badge - class="table-section section-30" :image-url="badge.renderedImageUrl" :link-url="badge.renderedLinkUrl" + class="table-section section-30" /> <span class="table-section section-50 str-truncated">{{ badge.linkUrl }}</span> <div class="table-section section-10"> @@ -54,29 +54,29 @@ export default { v-if="canEditBadge" class="table-action-buttons"> <button + :disabled="badge.isDeleting" class="btn btn-default append-right-8" type="button" - :disabled="badge.isDeleting" @click="editBadge(badge)" > <icon - name="pencil" :size="16" :aria-label="__('Edit')" + name="pencil" /> </button> <button + :disabled="badge.isDeleting" class="btn btn-danger" type="button" data-toggle="modal" data-target="#delete-badge-modal" - :disabled="badge.isDeleting" @click="updateBadgeInModal(badge)" > <icon - name="remove" :size="16" :aria-label="__('Delete')" + name="remove" /> </button> <loading-icon diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 83f78394238..cc47e56dd1e 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -44,8 +44,8 @@ export default { <gl-modal id="delete-badge-modal" :header-title-text="s__('Badges|Delete badge?')" - footer-primary-button-variant="danger" :footer-primary-button-text="s__('Badges|Delete badge')" + footer-primary-button-variant="danger" @submit="onSubmitModal"> <div class="well"> <badge diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index 75834ba351d..00419e80cbb 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -52,7 +52,7 @@ export default function initCopyToClipboard() { * data types to the intended values. */ $(document).on('copy', 'body > textarea[readonly]', (e) => { - const clipboardData = e.originalEvent.clipboardData; + const { clipboardData } = e.originalEvent; if (!clipboardData) return; const text = e.target.value; diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 75cf90de0b5..5d7a3bed301 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,4 +1,4 @@ -/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ +/* eslint-disable object-shorthand, no-unused-vars, no-use-before-define, max-len, no-restricted-syntax, guard-for-in, no-continue */ import $ from 'jquery'; import _ from 'underscore'; @@ -119,7 +119,7 @@ const gfmRules = { return el.outerHTML; }, 'dl'(el, text) { - let lines = text.trim().split('\n'); + let lines = text.replace(/\n\n/g, '\n').trim().split('\n'); // Add two spaces to the front of subsequent list items lines, // or leave the line entirely blank. lines = lines.map((l) => { @@ -129,9 +129,13 @@ const gfmRules = { return ` ${line}`; }); - return `<dl>\n${lines.join('\n')}\n</dl>`; + return `<dl>\n${lines.join('\n')}\n</dl>\n`; }, - 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) { + 'dt, dd, summary, details'(el, text) { + const tag = el.nodeName.toLowerCase(); + return `<${tag}>${text}</${tag}>\n`; + }, + 'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) { const tag = el.nodeName.toLowerCase(); return `<${tag}>${text}</${tag}>`; }, @@ -215,22 +219,22 @@ const gfmRules = { return text.replace(/^- /mg, '1. '); }, 'h1'(el, text) { - return `# ${text.trim()}`; + return `# ${text.trim()}\n`; }, 'h2'(el, text) { - return `## ${text.trim()}`; + return `## ${text.trim()}\n`; }, 'h3'(el, text) { - return `### ${text.trim()}`; + return `### ${text.trim()}\n`; }, 'h4'(el, text) { - return `#### ${text.trim()}`; + return `#### ${text.trim()}\n`; }, 'h5'(el, text) { - return `##### ${text.trim()}`; + return `##### ${text.trim()}\n`; }, 'h6'(el, text) { - return `###### ${text.trim()}`; + return `###### ${text.trim()}\n`; }, 'strong'(el, text) { return `**${text}**`; @@ -241,11 +245,13 @@ const gfmRules = { 'del'(el, text) { return `~~${text}~~`; }, - 'sup'(el, text) { - return `^${text}`; - }, 'hr'(el) { - return '-----'; + // extra leading \n is to ensure that there is a blank line between + // a list followed by an hr, otherwise this breaks old redcarpet rendering + return '\n-----\n'; + }, + 'p'(el, text) { + return `${text.trim()}\n`; }, 'table'(el) { const theadEl = el.querySelector('thead'); @@ -263,7 +269,9 @@ const gfmRules = { let before = ''; let after = ''; - switch (cell.style.textAlign) { + const alignment = cell.align || cell.style.textAlign; + + switch (alignment) { case 'center': before = ':'; after = ':'; @@ -313,7 +321,7 @@ export class CopyAsGFM { } static copyAsGFM(e, transformer) { - const clipboardData = e.originalEvent.clipboardData; + const { clipboardData } = e.originalEvent; if (!clipboardData) return; const documentFragment = getSelectedFragment(); @@ -330,7 +338,7 @@ export class CopyAsGFM { } static pasteGFM(e) { - const clipboardData = e.originalEvent.clipboardData; + const { clipboardData } = e.originalEvent; if (!clipboardData) return; const text = clipboardData.getData('text/plain'); diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js index 766039404ce..7986287f7e7 100644 --- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -84,7 +84,7 @@ class BalsamiqViewer { renderTemplate(preview) { const resource = this.getResource(preview.resourceID); const name = BalsamiqViewer.parseTitle(resource); - const image = preview.image; + const { image } = preview; const template = PREVIEW_TEMPLATE({ name, diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index 06ef86ecb77..b88e69a07bf 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -12,7 +12,7 @@ export default function loadBalsamiqFile() { if (!(viewer instanceof Element)) return; - const endpoint = viewer.dataset.endpoint; + const { endpoint } = viewer.dataset; const balsamiqViewer = new BalsamiqViewer(viewer); balsamiqViewer.loadFile(endpoint).catch(onError); diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js index 70136cc4087..7d5f487c4ba 100644 --- a/app/assets/javascripts/blob/pdf/index.js +++ b/app/assets/javascripts/blob/pdf/index.js @@ -1,4 +1,3 @@ -/* eslint-disable no-new */ import Vue from 'vue'; import pdfLab from '../../pdf/index.vue'; diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js index 63236b6477f..339906adc34 100644 --- a/app/assets/javascripts/blob/stl_viewer.js +++ b/app/assets/javascripts/blob/stl_viewer.js @@ -5,7 +5,7 @@ export default () => { [].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => { el.addEventListener('click', (e) => { - const target = e.target; + const { target } = e; e.preventDefault(); diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index f61c0be9230..5485248cfaf 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -70,7 +70,7 @@ export default class BlobViewer { const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)'); let initialViewerName = initialViewer.getAttribute('data-type'); - if (this.switcher && location.hash.indexOf('#L') === 0) { + if (this.switcher && window.location.hash.indexOf('#L') === 0) { initialViewerName = 'simple'; } diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 4424232f642..a603d89b84a 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -1,5 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, vars-on-top, no-unused-vars, no-new, max-len */ -/* global EditBlob */ +/* eslint-disable no-new */ import $ from 'jquery'; import NewCommitForm from '../new_commit_form'; diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index ac06d79fb60..a2355d7fd5c 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,6 +1,5 @@ -/* eslint-disable comma-dangle, space-before-function-paren, one-var */ +/* eslint-disable comma-dangle */ -import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; import AccessorUtilities from '../../lib/utils/accessor'; @@ -14,17 +13,28 @@ window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {}; gl.issueBoards.Board = Vue.extend({ - template: '#js-board-template', components: { boardList, 'board-delete': gl.issueBoards.BoardDelete, BoardBlankState, }, props: { - list: Object, - disabled: Boolean, - issueLinkBase: String, - rootPath: String, + list: { + type: Object, + default: () => ({}), + }, + disabled: { + type: Boolean, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, boardId: { type: String, required: true, @@ -46,56 +56,8 @@ gl.issueBoards.Board = Vue.extend({ }); }, deep: true, - }, - detailIssue: { - handler () { - if (!Object.keys(this.detailIssue.issue).length) return; - - const issue = this.list.findIssue(this.detailIssue.issue.id); - - if (issue) { - const offsetLeft = this.$el.offsetLeft; - const boardsList = document.querySelectorAll('.boards-list')[0]; - const left = boardsList.scrollLeft - offsetLeft; - let right = (offsetLeft + this.$el.offsetWidth); - - if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { - // -290 here because width of boardsList is animating so therefore - // getting the width here is incorrect - // 290 is the width of the sidebar - right -= (boardsList.offsetWidth - 290); - } else { - right -= boardsList.offsetWidth; - } - - if (right - boardsList.scrollLeft > 0) { - $(boardsList).animate({ - scrollLeft: right - }, this.sortableOptions.animation); - } else if (left > 0) { - $(boardsList).animate({ - scrollLeft: offsetLeft - }, this.sortableOptions.animation); - } - } - }, - deep: true } }, - methods: { - showNewIssueForm() { - this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - }, - toggleExpanded(e) { - if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { - this.list.isExpanded = !this.list.isExpanded; - - if (AccessorUtilities.isLocalStorageAccessSafe()) { - localStorage.setItem(`boards.${this.boardId}.${this.list.type}.expanded`, this.list.isExpanded); - } - } - }, - }, mounted () { this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ disabled: this.disabled, @@ -125,4 +87,19 @@ gl.issueBoards.Board = Vue.extend({ this.list.isExpanded = !isCollapsed; } }, + methods: { + showNewIssueForm() { + this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; + }, + toggleExpanded(e) { + if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { + this.list.isExpanded = !this.list.isExpanded; + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem(`boards.${this.boardId}.${this.list.type}.expanded`, this.list.isExpanded); + } + } + }, + }, + template: '#js-board-template', }); diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index 2049eeb9c30..286529b4d13 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -72,8 +72,8 @@ export default { :key="index" > <span - class="label-color" - :style="{ backgroundColor: label.color }"> + :style="{ backgroundColor: label.color }" + class="label-color"> </span> {{ label.title }} </li> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 33e3369b971..0398102ad02 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,83 +1,82 @@ <script> -/* eslint-disable vue/require-default-prop */ -import './issue_card_inner'; -import eventHub from '../eventhub'; + /* eslint-disable vue/require-default-prop */ + import IssueCardInner from './issue_card_inner.vue'; + import eventHub from '../eventhub'; -const Store = gl.issueBoards.BoardsStore; + const Store = gl.issueBoards.BoardsStore; -export default { - name: 'BoardsIssueCard', - components: { - 'issue-card-inner': gl.issueBoards.IssueCardInner, - }, - props: { - list: { - type: Object, - default: () => ({}), + export default { + name: 'BoardsIssueCard', + components: { + IssueCardInner, }, - issue: { - type: Object, - default: () => ({}), + props: { + list: { + type: Object, + default: () => ({}), + }, + issue: { + type: Object, + default: () => ({}), + }, + issueLinkBase: { + type: String, + default: '', + }, + disabled: { + type: Boolean, + default: false, + }, + index: { + type: Number, + default: 0, + }, + rootPath: { + type: String, + default: '', + }, + groupId: { + type: Number, + }, }, - issueLinkBase: { - type: String, - default: '', + data() { + return { + showDetail: false, + detailIssue: Store.detail, + }; }, - disabled: { - type: Boolean, - default: false, + computed: { + issueDetailVisible() { + return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id; + }, }, - index: { - type: Number, - default: 0, - }, - rootPath: { - type: String, - default: '', - }, - groupId: { - type: Number, - }, - }, - data() { - return { - showDetail: false, - detailIssue: Store.detail, - }; - }, - computed: { - issueDetailVisible() { - return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id; - }, - }, - methods: { - mouseDown() { - this.showDetail = true; - }, - mouseMove() { - this.showDetail = false; - }, - showIssue(e) { - if (e.target.classList.contains('js-no-trigger')) return; - - if (this.showDetail) { + methods: { + mouseDown() { + this.showDetail = true; + }, + mouseMove() { this.showDetail = false; + }, + showIssue(e) { + if (e.target.classList.contains('js-no-trigger')) return; + + if (this.showDetail) { + this.showDetail = false; - if (Store.detail.issue && Store.detail.issue.id === this.issue.id) { - eventHub.$emit('clearDetailIssue'); - } else { - eventHub.$emit('newDetailIssue', this.issue); - Store.detail.list = this.list; + if (Store.detail.issue && Store.detail.issue.id === this.issue.id) { + eventHub.$emit('clearDetailIssue'); + } else { + eventHub.$emit('newDetailIssue', this.issue); + Store.detail.list = this.list; + } } - } + }, }, - }, -}; + }; </script> <template> <li - class="board-card" :class="{ 'user-can-drag': !disabled && issue.id, 'is-disabled': disabled || !issue.id, @@ -85,6 +84,7 @@ export default { }" :index="index" :data-issue-id="issue.id" + class="board-card" @mousedown="mouseDown" @mousemove="mouseMove" @mouseup="showIssue($event)"> diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js index 7be98825fda..c5945e8098d 100644 --- a/app/assets/javascripts/boards/components/board_delete.js +++ b/app/assets/javascripts/boards/components/board_delete.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, space-before-function-paren, no-alert */ +/* eslint-disable comma-dangle, no-alert */ import $ from 'jquery'; import Vue from 'vue'; @@ -8,13 +8,16 @@ window.gl.issueBoards = window.gl.issueBoards || {}; gl.issueBoards.BoardDelete = Vue.extend({ props: { - list: Object + list: { + type: Object, + default: () => ({}), + }, }, methods: { deleteBoard () { $(this.$el).tooltip('hide'); - if (confirm('Are you sure you want to delete this list?')) { + if (window.confirm('Are you sure you want to delete this list?')) { this.list.destroy(); } } diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 84a7f277227..5c7565234d8 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -87,10 +87,46 @@ export default { mounted() { const options = gl.issueBoards.getBoardSortableDefaultOptions({ scroll: true, - group: 'issues', disabled: this.disabled, filter: '.board-list-count, .is-disabled', dataIdAttr: 'data-issue-id', + group: { + name: 'issues', + /** + * Dynamically determine between which containers + * items can be moved or copied as + * Assignee lists (EE feature) require this behavior + */ + pull: (to, from, dragEl, e) => { + // As per Sortable's docs, `to` should provide + // reference to exact sortable container on which + // we're trying to drag element, but either it is + // a library's bug or our markup structure is too complex + // that `to` never points to correct container + // See https://github.com/RubaXa/Sortable/issues/1037 + // + // So we use `e.target` which is always accurate about + // which element we're currently dragging our card upon + // So from there, we can get reference to actual container + // and thus the container type to enable Copy or Move + if (e.target) { + const containerEl = e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list'); + const toBoardType = containerEl.dataset.boardType; + + if (toBoardType) { + const fromBoardType = this.list.type; + + if ((fromBoardType === 'assignee' && toBoardType === 'label') || + (fromBoardType === 'label' && toBoardType === 'assignee')) { + return 'clone'; + } + } + } + + return true; + }, + revertClone: true, + }, onStart: (e) => { const card = this.$refs.issue[e.oldIndex]; @@ -169,21 +205,22 @@ export default { <template> <div class="board-list-component"> <div + v-if="loading" class="board-list-loading text-center" - aria-label="Loading issues" - v-if="loading"> + aria-label="Loading issues"> <loading-icon /> </div> <board-new-issue + v-if="list.type !== 'closed' && showIssueForm" :group-id="groupId" - :list="list" - v-if="list.type !== 'closed' && showIssueForm"/> + :list="list"/> <ul - class="board-list" v-show="!loading" ref="list" :data-board="list.id" - :class="{ 'is-smaller': showIssueForm }"> + :data-board-type="list.type" + :class="{ 'is-smaller': showIssueForm }" + class="board-list js-board-list"> <board-card v-for="(issue, index) in issues" ref="issue" @@ -196,8 +233,8 @@ export default { :disabled="disabled" :key="issue.id" /> <li - class="board-list-count text-center" v-if="showCount" + class="board-list-count text-center" data-issue-id="-1"> <loading-icon v-show="list.loadingMore" diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index e8dfd95f7ae..ec23b1e7c11 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -49,11 +49,12 @@ export default { this.error = false; const labels = this.list.label ? [this.list.label] : []; + const assignees = this.list.assignee ? [this.list.assignee] : []; const issue = new ListIssue({ title: this.title, labels, subscribed: true, - assignees: [], + assignees, project_id: this.selectedProject.id, }); @@ -95,26 +96,26 @@ export default { <div class="board-card"> <form @submit="submit($event)"> <div - class="flash-container" v-if="error" + class="flash-container" > <div class="flash-alert"> An error occurred. Please try again. </div> </div> <label - class="label-light" :for="list.id + '-title'" + class="label-light" > Title </label> <input + ref="input" + v-model="title" + :id="list.id + '-title'" class="form-control" type="text" - v-model="title" - ref="input" autocomplete="off" - :id="list.id + '-title'" /> <project-select v-if="groupId" @@ -122,10 +123,10 @@ export default { /> <div class="clearfix prepend-top-10"> <button + ref="submit-button" + :disabled="disabled" class="btn btn-success float-left" type="submit" - :disabled="disabled" - ref="submit-button" > Submit issue </button> @@ -141,4 +142,3 @@ export default { </div> </div> </template> - diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index c4ee4f6c855..371be109229 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, space-before-function-paren, no-new */ +/* eslint-disable comma-dangle, no-new */ import $ from 'jquery'; import Vue from 'vue'; @@ -6,13 +6,13 @@ import Flash from '../../flash'; import { __ } from '../../locale'; import Sidebar from '../../right_sidebar'; import eventHub from '../../sidebar/event_hub'; -import assigneeTitle from '../../sidebar/components/assignees/assignee_title.vue'; -import assignees from '../../sidebar/components/assignees/assignees.vue'; +import AssigneeTitle from '../../sidebar/components/assignees/assignee_title.vue'; +import Assignees from '../../sidebar/components/assignees/assignees.vue'; import DueDateSelectors from '../../due_date_select'; -import './sidebar/remove_issue'; +import RemoveBtn from './sidebar/remove_issue.vue'; import IssuableContext from '../../issuable_context'; import LabelsSelect from '../../labels_select'; -import subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue'; +import Subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue'; import MilestoneSelect from '../../milestone_select'; const Store = gl.issueBoards.BoardsStore; @@ -21,8 +21,17 @@ window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {}; gl.issueBoards.BoardSidebar = Vue.extend({ + components: { + AssigneeTitle, + Assignees, + RemoveBtn, + Subscriptions, + }, props: { - currentUser: Object + currentUser: { + type: Object, + default: () => ({}), + }, }, data() { return { @@ -64,6 +73,26 @@ gl.issueBoards.BoardSidebar = Vue.extend({ deep: true }, }, + created () { + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); + }, + mounted () { + new IssuableContext(this.currentUser); + new MilestoneSelect(); + new DueDateSelectors(); + new LabelsSelect(); + new Sidebar(); + }, methods: { closeSidebar () { this.detail.issue = {}; @@ -97,30 +126,4 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }); }, }, - created () { - // Get events from glDropdown - eventHub.$on('sidebar.removeAssignee', this.removeAssignee); - eventHub.$on('sidebar.addAssignee', this.addAssignee); - eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); - eventHub.$on('sidebar.saveAssignees', this.saveAssignees); - }, - beforeDestroy() { - eventHub.$off('sidebar.removeAssignee', this.removeAssignee); - eventHub.$off('sidebar.addAssignee', this.addAssignee); - eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); - eventHub.$off('sidebar.saveAssignees', this.saveAssignees); - }, - mounted () { - new IssuableContext(this.currentUser); - new MilestoneSelect(); - new DueDateSelectors(); - new LabelsSelect(); - new Sidebar(); - }, - components: { - assigneeTitle, - assignees, - removeBtn: gl.issueBoards.RemoveIssueBtn, - subscriptions, - }, }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js deleted file mode 100644 index dcc07810d01..00000000000 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ /dev/null @@ -1,195 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import eventHub from '../eventhub'; - -const Store = gl.issueBoards.BoardsStore; - -window.gl = window.gl || {}; -window.gl.issueBoards = window.gl.issueBoards || {}; - -gl.issueBoards.IssueCardInner = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - list: { - type: Object, - required: false, - default: () => ({}), - }, - rootPath: { - type: String, - required: true, - }, - updateFilters: { - type: Boolean, - required: false, - default: false, - }, - groupId: { - type: Number, - required: false, - }, - }, - data() { - return { - limitBeforeCounter: 3, - maxRender: 4, - maxCounter: 99, - }; - }, - components: { - UserAvatarLink, - }, - computed: { - numberOverLimit() { - return this.issue.assignees.length - this.limitBeforeCounter; - }, - assigneeCounterTooltip() { - return `${this.assigneeCounterLabel} more`; - }, - assigneeCounterLabel() { - if (this.numberOverLimit > this.maxCounter) { - return `${this.maxCounter}+`; - } - - return `+${this.numberOverLimit}`; - }, - shouldRenderCounter() { - if (this.issue.assignees.length <= this.maxRender) { - return false; - } - - return this.issue.assignees.length > this.numberOverLimit; - }, - issueId() { - if (this.issue.iid) { - return `#${this.issue.iid}`; - } - return false; - }, - showLabelFooter() { - return this.issue.labels.find(l => this.showLabel(l)) !== undefined; - }, - }, - methods: { - isIndexLessThanlimit(index) { - return index < this.limitBeforeCounter; - }, - shouldRenderAssignee(index) { - // Eg. maxRender is 4, - // Render up to all 4 assignees if there are only 4 assigness - // Otherwise render up to the limitBeforeCounter - if (this.issue.assignees.length <= this.maxRender) { - return index < this.maxRender; - } - - return index < this.limitBeforeCounter; - }, - assigneeUrl(assignee) { - return `${this.rootPath}${assignee.username}`; - }, - assigneeUrlTitle(assignee) { - return `Assigned to ${assignee.name}`; - }, - avatarUrlTitle(assignee) { - return `Avatar for ${assignee.name}`; - }, - showLabel(label) { - if (!label.id) return false; - return true; - }, - filterByLabel(label, e) { - if (!this.updateFilters) return; - - const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); - const labelTitle = encodeURIComponent(label.title); - const param = `label_name[]=${labelTitle}`; - const labelIndex = filterPath.indexOf(param); - $(e.currentTarget).tooltip('hide'); - - if (labelIndex === -1) { - filterPath.push(param); - } else { - filterPath.splice(labelIndex, 1); - } - - gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); - - Store.updateFiltersUrl(); - - eventHub.$emit('updateTokens'); - }, - labelStyle(label) { - return { - backgroundColor: label.color, - color: label.textColor, - }; - }, - }, - template: ` - <div> - <div class="board-card-header"> - <h4 class="board-card-title"> - <i - class="fa fa-eye-slash confidential-icon" - v-if="issue.confidential" - aria-hidden="true" - /> - <a - class="js-no-trigger" - :href="issue.path" - :title="issue.title">{{ issue.title }}</a> - <span - class="board-card-number" - v-if="issueId" - > - {{ issue.referencePath }} - </span> - </h4> - <div class="board-card-assignee"> - <user-avatar-link - v-for="(assignee, index) in issue.assignees" - :key="assignee.id" - v-if="shouldRenderAssignee(index)" - class="js-no-trigger" - :link-href="assigneeUrl(assignee)" - :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar" - :tooltip-text="assigneeUrlTitle(assignee)" - tooltip-placement="bottom" - /> - <span - class="avatar-counter has-tooltip" - :title="assigneeCounterTooltip" - v-if="shouldRenderCounter" - > - {{ assigneeCounterLabel }} - </span> - </div> - </div> - <div - class="board-card-footer" - v-if="showLabelFooter" - > - <button - class="badge color-label has-tooltip" - v-for="label in issue.labels" - type="button" - v-if="showLabel(label)" - @click="filterByLabel(label, $event)" - :style="labelStyle(label)" - :title="label.description" - data-container="body"> - {{ label.title }} - </button> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue new file mode 100644 index 00000000000..d50641dc3a9 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -0,0 +1,202 @@ +<script> + import $ from 'jquery'; + import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import eventHub from '../eventhub'; + import tooltip from '../../vue_shared/directives/tooltip'; + + const Store = gl.issueBoards.BoardsStore; + + export default { + components: { + UserAvatarLink, + }, + directives: { + tooltip, + }, + props: { + issue: { + type: Object, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + list: { + type: Object, + required: false, + default: () => ({}), + }, + rootPath: { + type: String, + required: true, + }, + updateFilters: { + type: Boolean, + required: false, + default: false, + }, + groupId: { + type: Number, + required: false, + default: null, + }, + }, + data() { + return { + limitBeforeCounter: 3, + maxRender: 4, + maxCounter: 99, + }; + }, + computed: { + numberOverLimit() { + return this.issue.assignees.length - this.limitBeforeCounter; + }, + assigneeCounterTooltip() { + return `${this.assigneeCounterLabel} more`; + }, + assigneeCounterLabel() { + if (this.numberOverLimit > this.maxCounter) { + return `${this.maxCounter}+`; + } + + return `+${this.numberOverLimit}`; + }, + shouldRenderCounter() { + if (this.issue.assignees.length <= this.maxRender) { + return false; + } + + return this.issue.assignees.length > this.numberOverLimit; + }, + issueId() { + if (this.issue.iid) { + return `#${this.issue.iid}`; + } + return false; + }, + showLabelFooter() { + return this.issue.labels.find(l => this.showLabel(l)) !== undefined; + }, + }, + methods: { + isIndexLessThanlimit(index) { + return index < this.limitBeforeCounter; + }, + shouldRenderAssignee(index) { + // Eg. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return index < this.maxRender; + } + + return index < this.limitBeforeCounter; + }, + assigneeUrl(assignee) { + return `${this.rootPath}${assignee.username}`; + }, + assigneeUrlTitle(assignee) { + return `Assigned to ${assignee.name}`; + }, + avatarUrlTitle(assignee) { + return `Avatar for ${assignee.name}`; + }, + showLabel(label) { + if (!label.id) return false; + return true; + }, + filterByLabel(label, e) { + if (!this.updateFilters) return; + + const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); + const labelTitle = encodeURIComponent(label.title); + const param = `label_name[]=${labelTitle}`; + const labelIndex = filterPath.indexOf(param); + $(e.currentTarget).tooltip('hide'); + + if (labelIndex === -1) { + filterPath.push(param); + } else { + filterPath.splice(labelIndex, 1); + } + + gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); + + Store.updateFiltersUrl(); + + eventHub.$emit('updateTokens'); + }, + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.textColor, + }; + }, + }, + }; +</script> +<template> + <div> + <div class="board-card-header"> + <h4 class="board-card-title"> + <i + v-if="issue.confidential" + class="fa fa-eye-slash confidential-icon" + aria-hidden="true" + ></i> + <a + :href="issue.path" + :title="issue.title" + class="js-no-trigger">{{ issue.title }}</a> + <span + v-if="issueId" + class="board-card-number" + > + {{ issue.referencePath }} + </span> + </h4> + <div class="board-card-assignee"> + <user-avatar-link + v-for="(assignee, index) in issue.assignees" + v-if="shouldRenderAssignee(index)" + :key="assignee.id" + :link-href="assigneeUrl(assignee)" + :img-alt="avatarUrlTitle(assignee)" + :img-src="assignee.avatar" + :tooltip-text="assigneeUrlTitle(assignee)" + class="js-no-trigger" + tooltip-placement="bottom" + /> + <span + v-tooltip + v-if="shouldRenderCounter" + :title="assigneeCounterTooltip" + class="avatar-counter" + > + {{ assigneeCounterLabel }} + </span> + </div> + </div> + <div + v-if="showLabelFooter" + class="board-card-footer" + > + <button + v-tooltip + v-for="label in issue.labels" + v-if="showLabel(label)" + :key="label.id" + :style="labelStyle(label)" + :title="label.description" + class="badge color-label" + type="button" + data-container="body" + @click="filterByLabel(label, $event)" + > + {{ label.title }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.vue index 1e5f2383223..dbd69f84526 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.js +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,12 +1,9 @@ -import Vue from 'vue'; +<script> import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; -gl.issueBoards.ModalEmptyState = Vue.extend({ +export default { mixins: [modalMixin], - data() { - return ModalStore.store; - }, props: { newIssuePath: { type: String, @@ -17,6 +14,9 @@ gl.issueBoards.ModalEmptyState = Vue.extend({ required: true, }, }, + data() { + return ModalStore.store; + }, computed: { contents() { const obj = { @@ -38,32 +38,36 @@ gl.issueBoards.ModalEmptyState = Vue.extend({ return obj; }, }, - template: ` - <section class="empty-state"> - <div class="row"> - <div class="col-12 col-md-6 order-md-last"> - <aside class="svg-content"><img :src="emptyStateSvg"/></aside> - </div> - <div class="col-12 col-md-6 order-md-first"> - <div class="text-content"> - <h4>{{ contents.title }}</h4> - <p v-html="contents.content"></p> - <a - :href="newIssuePath" - class="btn btn-success btn-inverted" - v-if="activeTab === 'all'"> - New issue - </a> - <button - type="button" - class="btn btn-default" - @click="changeTab('all')" - v-if="activeTab === 'selected'"> - Open issues - </button> - </div> +}; +</script> + +<template> + <section class="empty-state"> + <div class="row"> + <div class="col-12 col-md-6 order-md-last"> + <aside class="svg-content"><img :src="emptyStateSvg"/></aside> + </div> + <div class="col-12 col-md-6 order-md-first"> + <div class="text-content"> + <h4>{{ contents.title }}</h4> + <p v-html="contents.content"></p> + <a + v-if="activeTab === 'all'" + :href="newIssuePath" + class="btn btn-success btn-inverted" + > + New issue + </a> + <button + v-if="activeTab === 'selected'" + class="btn btn-default" + type="button" + @click="changeTab('all')" + > + Open issues + </button> </div> </div> - </section> - `, -}); + </div> + </section> +</template> diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.vue index 11bb3e98334..e0dac6003f1 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -1,12 +1,15 @@ -import Vue from 'vue'; +<script> import Flash from '../../../flash'; import { __ } from '../../../locale'; -import './lists_dropdown'; +import ListsDropdown from './lists_dropdown.vue'; import { pluralize } from '../../../lib/utils/text_utility'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; -gl.issueBoards.ModalFooter = Vue.extend({ +export default { + components: { + ListsDropdown, + }, mixins: [modalMixin], data() { return { @@ -52,31 +55,32 @@ gl.issueBoards.ModalFooter = Vue.extend({ this.toggleModal(false); }, }, - components: { - 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, - }, - template: ` - <footer - class="form-actions add-issues-footer"> - <div class="float-left"> - <button - class="btn btn-success" - type="button" - :disabled="submitDisabled" - @click="addIssues"> - {{ submitText }} - </button> - <span class="inline add-issues-footer-to-list"> - to list - </span> - <lists-dropdown></lists-dropdown> - </div> +}; +</script> +<template> + <footer + class="form-actions add-issues-footer" + > + <div class="float-left"> <button - class="btn btn-default float-right" + :disabled="submitDisabled" + class="btn btn-success" type="button" - @click="toggleModal(false)"> - Cancel + @click="addIssues" + > + {{ submitText }} </button> - </footer> - `, -}); + <span class="inline add-issues-footer-to-list"> + to list + </span> + <lists-dropdown/> + </div> + <button + class="btn btn-default float-right" + type="button" + @click="toggleModal(false)" + > + Cancel + </button> + </footer> +</template> diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js deleted file mode 100644 index 67c29ebca72..00000000000 --- a/app/assets/javascripts/boards/components/modal/header.js +++ /dev/null @@ -1,79 +0,0 @@ -import Vue from 'vue'; -import modalFilters from './filters'; -import './tabs'; -import ModalStore from '../../stores/modal_store'; -import modalMixin from '../../mixins/modal_mixins'; - -gl.issueBoards.ModalHeader = Vue.extend({ - mixins: [modalMixin], - props: { - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - selectAllText() { - if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return 'Select all'; - } - - return 'Deselect all'; - }, - showSearch() { - return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; - }, - }, - methods: { - toggleAll() { - this.$refs.selectAllBtn.blur(); - - ModalStore.toggleAll(); - }, - }, - components: { - 'modal-tabs': gl.issueBoards.ModalTabs, - modalFilters, - }, - template: ` - <div> - <header class="add-issues-header form-actions"> - <h2> - Add issues - <button - type="button" - class="close" - data-dismiss="modal" - aria-label="Close" - @click="toggleModal(false)"> - <span aria-hidden="true">×</span> - </button> - </h2> - </header> - <modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs> - <div - class="add-issues-search append-bottom-10" - v-if="showSearch"> - <modal-filters :store="filter" /> - <button - type="button" - class="btn btn-success btn-inverted prepend-left-10" - ref="selectAllBtn" - @click="toggleAll"> - {{ selectAllText }} - </button> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue new file mode 100644 index 00000000000..979fb4d7199 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -0,0 +1,82 @@ +<script> + import ModalFilters from './filters'; + import ModalTabs from './tabs.vue'; + import ModalStore from '../../stores/modal_store'; + import modalMixin from '../../mixins/modal_mixins'; + + export default { + components: { + ModalTabs, + ModalFilters, + }, + mixins: [modalMixin], + props: { + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + computed: { + selectAllText() { + if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { + return 'Select all'; + } + + return 'Deselect all'; + }, + showSearch() { + return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; + }, + }, + methods: { + toggleAll() { + this.$refs.selectAllBtn.blur(); + + ModalStore.toggleAll(); + }, + }, + }; +</script> +<template> + <div> + <header class="add-issues-header form-actions"> + <h2> + Add issues + <button + type="button" + class="close" + data-dismiss="modal" + aria-label="Close" + @click="toggleModal(false)" + > + <span aria-hidden="true">×</span> + </button> + </h2> + </header> + <modal-tabs v-if="!loading && issuesCount > 0"/> + <div + v-if="showSearch" + class="add-issues-search append-bottom-10"> + <modal-filters :store="filter" /> + <button + ref="selectAllBtn" + type="button" + class="btn btn-success btn-inverted prepend-left-10" + @click="toggleAll" + > + {{ selectAllText }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js deleted file mode 100644 index 3083b3e4405..00000000000 --- a/app/assets/javascripts/boards/components/modal/index.js +++ /dev/null @@ -1,171 +0,0 @@ -/* global ListIssue */ - -import Vue from 'vue'; -import queryData from '~/boards/utils/query_data'; -import loadingIcon from '~/vue_shared/components/loading_icon.vue'; -import './header'; -import './list'; -import './footer'; -import './empty_state'; -import ModalStore from '../../stores/modal_store'; - -gl.issueBoards.IssuesModal = Vue.extend({ - props: { - newIssuePath: { - type: String, - required: true, - }, - emptyStateSvg: { - type: String, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - watch: { - page() { - this.loadIssues(); - }, - showAddIssuesModal() { - if (this.showAddIssuesModal && !this.issues.length) { - this.loading = true; - const loadingDone = () => { - this.loading = false; - }; - - this.loadIssues() - .then(loadingDone) - .catch(loadingDone); - } else if (!this.showAddIssuesModal) { - this.issues = []; - this.selectedIssues = []; - this.issuesCount = false; - } - }, - filter: { - handler() { - if (this.$el.tagName) { - this.page = 1; - this.filterLoading = true; - const loadingDone = () => { - this.filterLoading = false; - }; - - this.loadIssues(true) - .then(loadingDone) - .catch(loadingDone); - } - }, - deep: true, - }, - }, - methods: { - loadIssues(clearIssues = false) { - if (!this.showAddIssuesModal) return false; - - return gl.boardService.getBacklog(queryData(this.filter.path, { - page: this.page, - per: this.perPage, - })) - .then(res => res.data) - .then((data) => { - if (clearIssues) { - this.issues = []; - } - - data.issues.forEach((issueObj) => { - const issue = new ListIssue(issueObj); - const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = !!foundSelectedIssue; - - this.issues.push(issue); - }); - - this.loadingNewPage = false; - - if (!this.issuesCount) { - this.issuesCount = data.size; - } - }).catch(() => { - // TODO: handle request error - }); - }, - }, - computed: { - showList() { - if (this.activeTab === 'selected') { - return this.selectedIssues.length > 0; - } - - return this.issuesCount > 0; - }, - showEmptyState() { - if (!this.loading && this.issuesCount === 0) { - return true; - } - - return this.activeTab === 'selected' && this.selectedIssues.length === 0; - }, - }, - created() { - this.page = 1; - }, - components: { - 'modal-header': gl.issueBoards.ModalHeader, - 'modal-list': gl.issueBoards.ModalList, - 'modal-footer': gl.issueBoards.ModalFooter, - 'empty-state': gl.issueBoards.ModalEmptyState, - loadingIcon, - }, - template: ` - <div - class="add-issues-modal" - v-if="showAddIssuesModal"> - <div class="add-issues-container"> - <modal-header - :project-id="projectId" - :milestone-path="milestonePath" - :label-path="labelPath"> - </modal-header> - <modal-list - :issue-link-base="issueLinkBase" - :root-path="rootPath" - :empty-state-svg="emptyStateSvg" - v-if="!loading && showList && !filterLoading"></modal-list> - <empty-state - v-if="showEmptyState" - :new-issue-path="newIssuePath" - :empty-state-svg="emptyStateSvg"></empty-state> - <section - class="add-issues-list text-center" - v-if="loading || filterLoading"> - <div class="add-issues-list-loading"> - <loading-icon /> - </div> - </section> - <modal-footer></modal-footer> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue new file mode 100644 index 00000000000..33e72a6782e --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -0,0 +1,178 @@ +<script> + /* global ListIssue */ + import queryData from '~/boards/utils/query_data'; + import loadingIcon from '~/vue_shared/components/loading_icon.vue'; + import ModalHeader from './header.vue'; + import ModalList from './list.vue'; + import ModalFooter from './footer.vue'; + import EmptyState from './empty_state.vue'; + import ModalStore from '../../stores/modal_store'; + + export default { + components: { + EmptyState, + ModalHeader, + ModalList, + ModalFooter, + loadingIcon, + }, + props: { + newIssuePath: { + type: String, + required: true, + }, + emptyStateSvg: { + type: String, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + computed: { + showList() { + if (this.activeTab === 'selected') { + return this.selectedIssues.length > 0; + } + + return this.issuesCount > 0; + }, + showEmptyState() { + if (!this.loading && this.issuesCount === 0) { + return true; + } + + return this.activeTab === 'selected' && this.selectedIssues.length === 0; + }, + }, + watch: { + page() { + this.loadIssues(); + }, + showAddIssuesModal() { + if (this.showAddIssuesModal && !this.issues.length) { + this.loading = true; + const loadingDone = () => { + this.loading = false; + }; + + this.loadIssues() + .then(loadingDone) + .catch(loadingDone); + } else if (!this.showAddIssuesModal) { + this.issues = []; + this.selectedIssues = []; + this.issuesCount = false; + } + }, + filter: { + handler() { + if (this.$el.tagName) { + this.page = 1; + this.filterLoading = true; + const loadingDone = () => { + this.filterLoading = false; + }; + + this.loadIssues(true) + .then(loadingDone) + .catch(loadingDone); + } + }, + deep: true, + }, + }, + created() { + this.page = 1; + }, + methods: { + loadIssues(clearIssues = false) { + if (!this.showAddIssuesModal) return false; + + return gl.boardService + .getBacklog( + queryData(this.filter.path, { + page: this.page, + per: this.perPage, + }), + ) + .then(res => res.data) + .then(data => { + if (clearIssues) { + this.issues = []; + } + + data.issues.forEach(issueObj => { + const issue = new ListIssue(issueObj); + const foundSelectedIssue = ModalStore.findSelectedIssue(issue); + issue.selected = !!foundSelectedIssue; + + this.issues.push(issue); + }); + + this.loadingNewPage = false; + + if (!this.issuesCount) { + this.issuesCount = data.size; + } + }) + .catch(() => { + // TODO: handle request error + }); + }, + }, + }; +</script> +<template> + <div + v-if="showAddIssuesModal" + class="add-issues-modal"> + <div class="add-issues-container"> + <modal-header + :project-id="projectId" + :milestone-path="milestonePath" + :label-path="labelPath" + /> + <modal-list + v-if="!loading && showList && !filterLoading" + :issue-link-base="issueLinkBase" + :root-path="rootPath" + :empty-state-svg="emptyStateSvg" + /> + <empty-state + v-if="showEmptyState" + :new-issue-path="newIssuePath" + :empty-state-svg="emptyStateSvg" + /> + <section + v-if="loading || filterLoading" + class="add-issues-list text-center" + > + <div class="add-issues-list-loading"> + <loading-icon /> + </div> + </section> + <modal-footer/> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js deleted file mode 100644 index f86896d2178..00000000000 --- a/app/assets/javascripts/boards/components/modal/list.js +++ /dev/null @@ -1,159 +0,0 @@ -import Vue from 'vue'; -import bp from '../../../breakpoints'; -import ModalStore from '../../stores/modal_store'; - -gl.issueBoards.ModalList = Vue.extend({ - props: { - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - emptyStateSvg: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - watch: { - activeTab() { - if (this.activeTab === 'all') { - ModalStore.purgeUnselectedIssues(); - } - }, - }, - computed: { - loopIssues() { - if (this.activeTab === 'all') { - return this.issues; - } - - return this.selectedIssues; - }, - groupedIssues() { - const groups = []; - this.loopIssues.forEach((issue, i) => { - const index = i % this.columns; - - if (!groups[index]) { - groups.push([]); - } - - groups[index].push(issue); - }); - - return groups; - }, - }, - methods: { - scrollHandler() { - const currentPage = Math.floor(this.issues.length / this.perPage); - - if ( - this.scrollTop() > this.scrollHeight() - 100 && - !this.loadingNewPage && - currentPage === this.page - ) { - this.loadingNewPage = true; - this.page += 1; - } - }, - toggleIssue(e, issue) { - if (e.target.tagName !== 'A') { - ModalStore.toggleIssue(issue); - } - }, - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - showIssue(issue) { - if (this.activeTab === 'all') return true; - - const index = ModalStore.selectedIssueIndex(issue); - - return index !== -1; - }, - setColumnCount() { - const breakpoint = bp.getBreakpointSize(); - - if (breakpoint === 'lg' || breakpoint === 'md') { - this.columns = 3; - } else if (breakpoint === 'sm') { - this.columns = 2; - } else { - this.columns = 1; - } - }, - }, - mounted() { - this.scrollHandlerWrapper = this.scrollHandler.bind(this); - this.setColumnCountWrapper = this.setColumnCount.bind(this); - this.setColumnCount(); - - this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); - window.addEventListener('resize', this.setColumnCountWrapper); - }, - beforeDestroy() { - this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); - window.removeEventListener('resize', this.setColumnCountWrapper); - }, - components: { - 'issue-card-inner': gl.issueBoards.IssueCardInner, - }, - template: ` - <section - class="add-issues-list add-issues-list-columns" - ref="list"> - <div - class="empty-state add-issues-empty-state-filter text-center" - v-if="issuesCount > 0 && issues.length === 0"> - <div - class="svg-content"> - <img :src="emptyStateSvg"/> - </div> - <div class="text-content"> - <h4> - There are no issues to show. - </h4> - </div> - </div> - <div - v-for="group in groupedIssues" - class="add-issues-list-column"> - <div - v-for="issue in group" - v-if="showIssue(issue)" - class="board-card-parent"> - <div - class="board-card" - :class="{ 'is-active': issue.selected }" - @click="toggleIssue($event, issue)"> - <issue-card-inner - :issue="issue" - :issue-link-base="issueLinkBase" - :root-path="rootPath"> - </issue-card-inner> - <span - :aria-label="'Issue #' + issue.id + ' selected'" - aria-checked="true" - v-if="issue.selected" - class="issue-card-selected text-center"> - <i class="fa fa-check"></i> - </span> - </div> - </div> - </div> - </section> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue new file mode 100644 index 00000000000..02ac36d7367 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -0,0 +1,162 @@ +<script> + import bp from '../../../breakpoints'; + import ModalStore from '../../stores/modal_store'; + import IssueCardInner from '../issue_card_inner.vue'; + + export default { + components: { + IssueCardInner, + }, + props: { + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + emptyStateSvg: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + computed: { + loopIssues() { + if (this.activeTab === 'all') { + return this.issues; + } + + return this.selectedIssues; + }, + groupedIssues() { + const groups = []; + this.loopIssues.forEach((issue, i) => { + const index = i % this.columns; + + if (!groups[index]) { + groups.push([]); + } + + groups[index].push(issue); + }); + + return groups; + }, + }, + watch: { + activeTab() { + if (this.activeTab === 'all') { + ModalStore.purgeUnselectedIssues(); + } + }, + }, + mounted() { + this.scrollHandlerWrapper = this.scrollHandler.bind(this); + this.setColumnCountWrapper = this.setColumnCount.bind(this); + this.setColumnCount(); + + this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); + window.addEventListener('resize', this.setColumnCountWrapper); + }, + beforeDestroy() { + this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); + window.removeEventListener('resize', this.setColumnCountWrapper); + }, + methods: { + scrollHandler() { + const currentPage = Math.floor(this.issues.length / this.perPage); + + if ( + this.scrollTop() > this.scrollHeight() - 100 && + !this.loadingNewPage && + currentPage === this.page + ) { + this.loadingNewPage = true; + this.page += 1; + } + }, + toggleIssue(e, issue) { + if (e.target.tagName !== 'A') { + ModalStore.toggleIssue(issue); + } + }, + listHeight() { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight() { + return this.$refs.list.scrollHeight; + }, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); + }, + showIssue(issue) { + if (this.activeTab === 'all') return true; + + const index = ModalStore.selectedIssueIndex(issue); + + return index !== -1; + }, + setColumnCount() { + const breakpoint = bp.getBreakpointSize(); + + if (breakpoint === 'lg' || breakpoint === 'md') { + this.columns = 3; + } else if (breakpoint === 'sm') { + this.columns = 2; + } else { + this.columns = 1; + } + }, + }, + }; +</script> +<template> + <section + ref="list" + class="add-issues-list add-issues-list-columns"> + <div + v-if="issuesCount > 0 && issues.length === 0" + class="empty-state add-issues-empty-state-filter text-center"> + <div + class="svg-content"> + <img :src="emptyStateSvg" /> + </div> + <div class="text-content"> + <h4> + There are no issues to show. + </h4> + </div> + </div> + <div + v-for="(group, index) in groupedIssues" + :key="index" + class="add-issues-list-column"> + <div + v-for="issue in group" + v-if="showIssue(issue)" + :key="issue.id" + class="board-card-parent"> + <div + :class="{ 'is-active': issue.selected }" + class="board-card" + @click="toggleIssue($event, issue)"> + <issue-card-inner + :issue="issue" + :issue-link-base="issueLinkBase" + :root-path="rootPath"/> + <span + v-if="issue.selected" + :aria-label="'Issue #' + issue.id + ' selected'" + aria-checked="true" + class="issue-card-selected text-center"> + <i class="fa fa-check"></i> + </span> + </div> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js deleted file mode 100644 index e644de2d4fc..00000000000 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js +++ /dev/null @@ -1,54 +0,0 @@ -import Vue from 'vue'; -import ModalStore from '../../stores/modal_store'; - -gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ - data() { - return { - modal: ModalStore.store, - state: gl.issueBoards.BoardsStore.state, - }; - }, - computed: { - selected() { - return this.modal.selectedList || this.state.lists[1]; - }, - }, - destroyed() { - this.modal.selectedList = null; - }, - template: ` - <div class="dropdown inline"> - <button - class="dropdown-menu-toggle" - type="button" - data-toggle="dropdown" - aria-expanded="false"> - <span - class="dropdown-label-box" - :style="{ backgroundColor: selected.label.color }"> - </span> - {{ selected.title }} - <i class="fa fa-chevron-down"></i> - </button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> - <ul> - <li - v-for="list in state.lists" - v-if="list.type == 'label'"> - <a - href="#" - role="button" - :class="{ 'is-active': list.id == selected.id }" - @click.prevent="modal.selectedList = list"> - <span - class="dropdown-label-box" - :style="{ backgroundColor: list.label.color }"> - </span> - {{ list.title }} - </a> - </li> - </ul> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue new file mode 100644 index 00000000000..6a5a39099bd --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue @@ -0,0 +1,56 @@ +<script> +import ModalStore from '../../stores/modal_store'; + +export default { + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + selected() { + return this.modal.selectedList || this.state.lists[1]; + }, + }, + destroyed() { + this.modal.selectedList = null; + }, +}; +</script> +<template> + <div class="dropdown inline"> + <button + class="dropdown-menu-toggle" + type="button" + data-toggle="dropdown" + aria-expanded="false"> + <span + :style="{ backgroundColor: selected.label.color }" + class="dropdown-label-box"> + </span> + {{ selected.title }} + <i class="fa fa-chevron-down"></i> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> + <ul> + <li + v-for="(list, i) in state.lists" + v-if="list.type == 'label'" + :key="i"> + <a + :class="{ 'is-active': list.id == selected.id }" + href="#" + role="button" + @click.prevent="modal.selectedList = list"> + <span + :style="{ backgroundColor: list.label.color }" + class="dropdown-label-box"> + </span> + {{ list.title }} + </a> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js deleted file mode 100644 index 9d331de8e22..00000000000 --- a/app/assets/javascripts/boards/components/modal/tabs.js +++ /dev/null @@ -1,46 +0,0 @@ -import Vue from 'vue'; -import ModalStore from '../../stores/modal_store'; -import modalMixin from '../../mixins/modal_mixins'; - -gl.issueBoards.ModalTabs = Vue.extend({ - mixins: [modalMixin], - data() { - return ModalStore.store; - }, - computed: { - selectedCount() { - return ModalStore.selectedCount(); - }, - }, - destroyed() { - this.activeTab = 'all'; - }, - template: ` - <div class="top-area prepend-top-10 append-bottom-10"> - <ul class="nav-links issues-state-filters"> - <li :class="{ 'active': activeTab == 'all' }"> - <a - href="#" - role="button" - @click.prevent="changeTab('all')"> - Open issues - <span class="badge badge-pill"> - {{ issuesCount }} - </span> - </a> - </li> - <li :class="{ 'active': activeTab == 'selected' }"> - <a - href="#" - role="button" - @click.prevent="changeTab('selected')"> - Selected issues - <span class="badge badge-pill"> - {{ selectedCount }} - </span> - </a> - </li> - </ul> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue new file mode 100644 index 00000000000..d926b080094 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/tabs.vue @@ -0,0 +1,49 @@ +<script> + import ModalStore from '../../stores/modal_store'; + import modalMixin from '../../mixins/modal_mixins'; + + export default { + mixins: [modalMixin], + data() { + return ModalStore.store; + }, + computed: { + selectedCount() { + return ModalStore.selectedCount(); + }, + }, + destroyed() { + this.activeTab = 'all'; + }, + }; +</script> +<template> + <div class="top-area prepend-top-10 append-bottom-10"> + <ul class="nav-links issues-state-filters"> + <li :class="{ 'active': activeTab == 'all' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('all')" + > + Open issues + <span class="badge badge-pill"> + {{ issuesCount }} + </span> + </a> + </li> + <li :class="{ 'active': activeTab == 'selected' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('selected')" + > + Selected issues + <span class="badge badge-pill"> + {{ selectedCount }} + </span> + </a> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 71f49319c36..448ab9ed135 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-new, space-before-function-paren, one-var, promise/catch-or-return, max-len */ +/* eslint-disable func-names, no-new, promise/catch-or-return */ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; @@ -56,6 +56,7 @@ gl.issueBoards.newListDropdownInit = () => { filterable: true, selectable: true, multiSelect: true, + containerSelector: '.js-tab-container-labels .dropdown-page-one .dropdown-content', clicked (options) { const { e } = options; const label = options.selectedObj; diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js deleted file mode 100644 index 0a0820ec5fd..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js +++ /dev/null @@ -1,73 +0,0 @@ -import Vue from 'vue'; -import Flash from '../../../flash'; -import { __ } from '../../../locale'; - -const Store = gl.issueBoards.BoardsStore; - -window.gl = window.gl || {}; -window.gl.issueBoards = window.gl.issueBoards || {}; - -gl.issueBoards.RemoveIssueBtn = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - list: { - type: Object, - required: true, - }, - }, - computed: { - updateUrl() { - return this.issue.path; - }, - }, - methods: { - removeIssue() { - const issue = this.issue; - const lists = issue.getLists(); - const listLabelIds = lists.map(list => list.label.id); - - let labelIds = issue.labels - .map(label => label.id) - .filter(id => !listLabelIds.includes(id)); - if (labelIds.length === 0) { - labelIds = ['']; - } - - const data = { - issue: { - label_ids: labelIds, - }, - }; - - // Post the remove data - Vue.http.patch(this.updateUrl, data).catch(() => { - Flash(__('Failed to remove issue from board, please try again.')); - - lists.forEach((list) => { - list.addIssue(issue); - }); - }); - - // Remove from the frontend store - lists.forEach((list) => { - list.removeIssue(issue); - }); - - Store.detail.issue = {}; - }, - }, - template: ` - <div - class="block list"> - <button - class="btn btn-default btn-block" - type="button" - @click="removeIssue"> - Remove from board - </button> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue new file mode 100644 index 00000000000..55278626ffc --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -0,0 +1,72 @@ +<script> + import Vue from 'vue'; + import Flash from '../../../flash'; + import { __ } from '../../../locale'; + + const Store = gl.issueBoards.BoardsStore; + + export default { + props: { + issue: { + type: Object, + required: true, + }, + list: { + type: Object, + required: true, + }, + }, + computed: { + updateUrl() { + return this.issue.path; + }, + }, + methods: { + removeIssue() { + const { issue } = this; + const lists = issue.getLists(); + const listLabelIds = lists.map(list => list.label.id); + + let labelIds = issue.labels.map(label => label.id).filter(id => !listLabelIds.includes(id)); + if (labelIds.length === 0) { + labelIds = ['']; + } + + const data = { + issue: { + label_ids: labelIds, + }, + }; + + // Post the remove data + Vue.http.patch(this.updateUrl, data).catch(() => { + Flash(__('Failed to remove issue from board, please try again.')); + + lists.forEach(list => { + list.addIssue(issue); + }); + }); + + // Remove from the frontend store + lists.forEach(list => { + list.removeIssue(issue); + }); + + Store.detail.issue = {}; + }, + }, + }; +</script> +<template> + <div + class="block list" + > + <button + class="btn btn-default btn-block" + type="button" + @click="removeIssue" + > + Remove from board + </button> + </div> +</template> diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 70367c4f711..46d61ebbf24 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,4 +1,3 @@ -/* eslint-disable class-methods-use-this */ import FilteredSearchContainer from '../filtered_search/container'; import FilteredSearchManager from '../filtered_search/filtered_search_manager'; diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js index 70132dbfa6f..9eaa0cd227d 100644 --- a/app/assets/javascripts/boards/filters/due_date_filters.js +++ b/app/assets/javascripts/boards/filters/due_date_filters.js @@ -1,8 +1,7 @@ -/* global dateFormat */ - import Vue from 'vue'; +import dateFormat from 'dateformat'; -Vue.filter('due-date', (value) => { +Vue.filter('due-date', value => { const date = new Date(value); return dateFormat(date, 'mmm d, yyyy', true); }); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 29ab13b8e0b..200d1923635 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,4 +1,4 @@ -/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ +/* eslint-disable quote-props, comma-dangle */ import $ from 'jquery'; import _ from 'underscore'; @@ -7,6 +7,7 @@ import Vue from 'vue'; import Flash from '~/flash'; import { __ } from '~/locale'; import '~/vue_shared/models/label'; +import '~/vue_shared/models/assignee'; import FilteredSearchBoards from './filtered_search_boards'; import eventHub from './eventhub'; @@ -15,7 +16,6 @@ import './models/issue'; import './models/list'; import './models/milestone'; import './models/project'; -import './models/assignee'; import './stores/boards_store'; import ModalStore from './stores/modal_store'; import BoardService from './services/board_service'; @@ -25,7 +25,7 @@ import './filters/due_date_filters'; import './components/board'; import './components/board_sidebar'; import './components/new_list_dropdown'; -import './components/modal/index'; +import BoardAddIssuesModal from './components/modal/index.vue'; import '~/vue_shared/vue_resource_interceptor'; // eslint-disable-line import/first export default () => { @@ -49,7 +49,7 @@ export default () => { components: { 'board': gl.issueBoards.Board, 'board-sidebar': gl.issueBoards.BoardSidebar, - 'board-add-issues-modal': gl.issueBoards.IssuesModal, + BoardAddIssuesModal, }, data: { state: Store.state, @@ -121,7 +121,7 @@ export default () => { this.filterManager.updateTokens(); }, updateDetailIssue(newIssue) { - const sidebarInfoEndpoint = newIssue.sidebarInfoEndpoint; + const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { newIssue.setFetchingState('subscriptions', true); BoardService.getIssueInfo(sidebarInfoEndpoint) @@ -144,7 +144,7 @@ export default () => { Store.detail.issue = {}; }, toggleSubscription(id) { - const issue = Store.detail.issue; + const { issue } = Store.detail; if (issue.id === id && issue.toggleSubscriptionEndpoint) { issue.setFetchingState('subscriptions', true); BoardService.toggleIssueSubscription(issue.toggleSubscriptionEndpoint) diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index ac316c31deb..a8df45fc473 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ /* global DocumentTouch */ import $ from 'jquery'; diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js deleted file mode 100644 index 05dd449e4fd..00000000000 --- a/app/assets/javascripts/boards/models/assignee.js +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable no-unused-vars */ - -class ListAssignee { - constructor(user, defaultAvatar) { - this.id = user.id; - this.name = user.name; - this.username = user.username; - this.avatar = user.avatar_url || defaultAvatar; - } -} - -window.ListAssignee = ListAssignee; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index b381d48d625..b85266b6bc3 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -1,4 +1,4 @@ -/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ +/* eslint-disable no-unused-vars, comma-dangle */ /* global ListLabel */ /* global ListMilestone */ /* global ListAssignee */ diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 7144f4190e7..e35f277a865 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,12 +1,14 @@ -/* eslint-disable space-before-function-paren, no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign, max-len, no-unused-vars */ +/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign, max-len */ /* global ListIssue */ -/* global ListLabel */ + +import ListLabel from '~/vue_shared/models/label'; +import ListAssignee from '~/vue_shared/models/assignee'; import queryData from '../utils/query_data'; const PER_PAGE = 20; class List { - constructor (obj, defaultAvatar) { + constructor(obj, defaultAvatar) { this.id = obj.id; this._uid = this.guid(); this.position = obj.position; @@ -24,6 +26,9 @@ class List { if (obj.label) { this.label = new ListLabel(obj.label); + } else if (obj.user) { + this.assignee = new ListAssignee(obj.user); + this.title = this.assignee.name; } if (this.type !== 'blank' && this.id) { @@ -34,14 +39,26 @@ class List { } guid() { - const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); + const s4 = () => + Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; } - save () { - return gl.boardService.createList(this.label.id) + save() { + const entity = this.label || this.assignee; + let entityType = ''; + if (this.label) { + entityType = 'label_id'; + } else { + entityType = 'assignee_id'; + } + + return gl.boardService + .createList(entity.id, entityType) .then(res => res.data) - .then((data) => { + .then(data => { this.id = data.id; this.type = data.list_type; this.position = data.position; @@ -50,25 +67,23 @@ class List { }); } - destroy () { + destroy() { const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this); gl.issueBoards.BoardsStore.state.lists.splice(index, 1); gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); - gl.boardService.destroyList(this.id) - .catch(() => { - // TODO: handle request error - }); + gl.boardService.destroyList(this.id).catch(() => { + // TODO: handle request error + }); } - update () { - gl.boardService.updateList(this.id, this.position) - .catch(() => { - // TODO: handle request error - }); + update() { + gl.boardService.updateList(this.id, this.position).catch(() => { + // TODO: handle request error + }); } - nextPage () { + nextPage() { if (this.issuesSize > this.issues.length) { if (this.issues.length / PER_PAGE >= 1) { this.page += 1; @@ -78,7 +93,7 @@ class List { } } - getIssues (emptyIssues = true) { + getIssues(emptyIssues = true) { const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page }); if (this.label && data.label_name) { @@ -89,7 +104,8 @@ class List { this.loading = true; } - return gl.boardService.getIssuesForList(this.id, data) + return gl.boardService + .getIssuesForList(this.id, data) .then(res => res.data) .then((data) => { this.loading = false; @@ -103,11 +119,12 @@ class List { }); } - newIssue (issue) { + newIssue(issue) { this.addIssue(issue, null, 0); this.issuesSize += 1; - return gl.boardService.newIssue(this.id, issue) + return gl.boardService + .newIssue(this.id, issue) .then(res => res.data) .then((data) => { issue.id = data.id; @@ -123,13 +140,13 @@ class List { }); } - createIssues (data) { - data.forEach((issueObj) => { + createIssues(data) { + data.forEach(issueObj => { this.addIssue(new ListIssue(issueObj, this.defaultAvatar)); }); } - addIssue (issue, listFrom, newIndex) { + addIssue(issue, listFrom, newIndex) { let moveBeforeId = null; let moveAfterId = null; @@ -152,6 +169,13 @@ class List { issue.addLabel(this.label); } + if (this.assignee) { + if (listFrom && listFrom.type === 'assignee') { + issue.removeAssignee(listFrom.assignee); + } + issue.addAssignee(this.assignee); + } + if (listFrom) { this.issuesSize += 1; @@ -160,29 +184,29 @@ class List { } } - moveIssue (issue, oldIndex, newIndex, moveBeforeId, moveAfterId) { + moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) { this.issues.splice(oldIndex, 1); this.issues.splice(newIndex, 0, issue); - gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId) - .catch(() => { - // TODO: handle request error - }); + gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => { + // TODO: handle request error + }); } updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { - gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) + gl.boardService + .moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId) .catch(() => { // TODO: handle request error }); } - findIssue (id) { + findIssue(id) { return this.issues.find(issue => issue.id === id); } - removeIssue (removeIssue) { - this.issues = this.issues.filter((issue) => { + removeIssue(removeIssue) { + this.issues = this.issues.filter(issue => { const matchesRemove = removeIssue.id === issue.id; if (matchesRemove) { diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js index c867b06d320..17d15278a74 100644 --- a/app/assets/javascripts/boards/models/milestone.js +++ b/app/assets/javascripts/boards/models/milestone.js @@ -1,5 +1,3 @@ -/* eslint-disable no-unused-vars */ - class ListMilestone { constructor(obj) { this.id = obj.id; diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index 7c90597f77c..029b0971f2c 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -30,11 +30,13 @@ export default class BoardService { return axios.post(this.listsEndpointGenerate, {}); } - createList(labelId) { + createList(entityId, entityType) { + const list = { + [entityType]: entityId, + }; + return axios.post(this.listsEndpoint, { - list: { - label_id: labelId, - }, + list, }); } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 20e78edf2a2..333338489bc 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, space-before-function-paren, one-var, no-shadow, dot-notation, max-len */ +/* eslint-disable comma-dangle, no-shadow */ /* global List */ import $ from 'jquery'; @@ -103,8 +103,15 @@ gl.issueBoards.BoardsStore = { const listLabels = issueLists.map(listIssue => listIssue.label); if (!issueTo) { - // Add to new lists issues if it doesn't already exist - listTo.addIssue(issue, listFrom, newIndex); + // Check if target list assignee is already present in this issue + if ((listTo.type === 'assignee' && listFrom.type === 'assignee') && + issue.findAssignee(listTo.assignee)) { + const targetIssue = listTo.findIssue(issue.id); + targetIssue.removeAssignee(listFrom.assignee); + } else { + // Add to new lists issues if it doesn't already exist + listTo.addIssue(issue, listFrom, newIndex); + } } else { listTo.updateIssueLabel(issue, listFrom); issueTo.removeLabel(listFrom.label); @@ -115,7 +122,11 @@ gl.issueBoards.BoardsStore = { list.removeIssue(issue); }); issue.removeLabels(listLabels); - } else { + } else if (listTo.type === 'backlog' && listFrom.type === 'assignee') { + issue.removeAssignee(listFrom.assignee); + listFrom.removeIssue(issue); + } else if ((listTo.type !== 'label' && listFrom.type === 'assignee') || + (listTo.type !== 'assignee' && listFrom.type === 'label')) { listFrom.removeIssue(issue); } }, @@ -126,13 +137,14 @@ gl.issueBoards.BoardsStore = { list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); }, findList (key, val, type = 'label') { - return this.state.lists.filter((list) => { - const byType = type ? list['type'] === type : true; + const filteredList = this.state.lists.filter((list) => { + const byType = type ? (list.type === type) || (list.type === 'assignee') : true; return list[key] === val && byType; - })[0]; + }); + return filteredList[0]; }, updateFiltersUrl () { - history.pushState(null, null, `?${this.filter.path}`); + window.history.pushState(null, null, `?${this.filter.path}`); } }; diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js index a4220cd840d..0d9ac367a70 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -26,7 +26,7 @@ class ModalStore { toggleIssue(issueObj) { const issue = issueObj; - const selected = issue.selected; + const { selected } = issue; issue.selected = !selected; diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index 3fa16517388..e338376fcaa 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-arrow-callback, no-return-assign */ +/* eslint-disable func-names, prefer-arrow-callback */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index e42a3632e79..8139aa69fc7 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -81,7 +81,7 @@ export default class Clusters { } initApplications() { - const store = this.store; + const { store } = this; const el = document.querySelector('#js-cluster-applications'); this.applications = new Vue({ diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 30567993322..ec52fdfdf32 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -125,8 +125,8 @@ <template> <div - class="gl-responsive-table-row gl-responsive-table-row-col-span" :class="rowJsClass" + class="gl-responsive-table-row gl-responsive-table-row-col-span" > <div class="gl-responsive-table-row-layout" @@ -155,8 +155,8 @@ <slot name="description"></slot> </div> <div - class="table-section table-button-footer section-align-top" :class="{ 'section-20': showManageButton, 'section-15': !showManageButton }" + class="table-section table-button-footer section-align-top" role="gridcell" > <div @@ -164,18 +164,18 @@ class="btn-group table-action-buttons" > <a - class="btn" :href="manageLink" + class="btn" > {{ manageButtonLabel }} </a> </div> <div class="btn-group table-action-buttons"> <loading-button - class="js-cluster-application-install-button" :loading="installButtonLoading" :disabled="installButtonDisabled" :label="installButtonLabel" + class="js-cluster-application-install-button" @click="installClicked" /> </div> @@ -187,7 +187,7 @@ role="row" > <div - class="alert alert-danger alert-block append-bottom-0" + class="alert alert-danger alert-block append-bottom-0 clusters-error-alert" role="gridcell" > <div> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 9d6be555a2c..8ee7279e544 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -152,11 +152,11 @@ export default { <application-row id="helm" :title="applications.helm.title" - title-link="https://docs.helm.sh/" :status="applications.helm.status" :status-reason="applications.helm.statusReason" :request-status="applications.helm.requestStatus" :request-reason="applications.helm.requestReason" + title-link="https://docs.helm.sh/" > <div slot="description"> {{ s__(`ClusterIntegration|Helm streamlines installing @@ -168,11 +168,11 @@ export default { <application-row :id="ingressId" :title="applications.ingress.title" - title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" :status="applications.ingress.status" :status-reason="applications.ingress.statusReason" :request-status="applications.ingress.requestStatus" :request-reason="applications.ingress.requestReason" + title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" > <div slot="description"> <p> @@ -191,10 +191,10 @@ export default { class="input-group" > <input - type="text" id="ingress-ip-address" - class="form-control js-ip-address" :value="ingressExternalIp" + type="text" + class="form-control js-ip-address" readonly /> <span class="input-group-append"> @@ -255,12 +255,12 @@ export default { <application-row id="prometheus" :title="applications.prometheus.title" - title-link="https://prometheus.io/docs/introduction/overview/" :manage-link="managePrometheusPath" :status="applications.prometheus.status" :status-reason="applications.prometheus.statusReason" :request-status="applications.prometheus.requestStatus" :request-reason="applications.prometheus.requestReason" + title-link="https://prometheus.io/docs/introduction/overview/" > <div slot="description" @@ -271,11 +271,11 @@ export default { <application-row id="runner" :title="applications.runner.title" - title-link="https://docs.gitlab.com/runner/" :status="applications.runner.status" :status-reason="applications.runner.statusReason" :request-status="applications.runner.requestStatus" :request-reason="applications.runner.requestReason" + title-link="https://docs.gitlab.com/runner/" > <div slot="description"> {{ s__(`ClusterIntegration|GitLab Runner connects to this @@ -287,12 +287,12 @@ export default { <application-row id="jupyter" :title="applications.jupyter.title" - title-link="https://jupyterhub.readthedocs.io/en/stable/" :status="applications.jupyter.status" :status-reason="applications.jupyter.statusReason" :request-status="applications.jupyter.requestStatus" :request-reason="applications.jupyter.requestReason" :install-application-request-params="{ hostname: applications.jupyter.hostname }" + title-link="https://jupyterhub.readthedocs.io/en/stable/" > <div slot="description"> <p> @@ -311,10 +311,10 @@ export default { <div class="input-group"> <input - type="text" - class="form-control js-hostname" v-model="applications.jupyter.hostname" :readonly="jupyterInstalled" + type="text" + class="form-control js-hostname" /> <span class="input-group-btn" diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 3a4ac09f67c..d90db7b103c 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -95,7 +95,7 @@ export default class ClusterStore { this.state.applications.jupyter.hostname = serverAppEntry.hostname || (this.state.applications.ingress.externalIp - ? `jupyter.${this.state.applications.ingress.externalIp}.xip.io` + ? `jupyter.${this.state.applications.ingress.externalIp}.nip.io` : ''); } }); diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 7f3d04655a7..410580b4c25 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-use-before-define, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, quotes, one-var, one-var-declaration-per-line, no-unused-vars, no-return-assign, comma-dangle, quote-props, no-unused-expressions, no-sequences, object-shorthand, max-len */ +/* eslint-disable func-names, wrap-iife, no-var, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, quotes, one-var, one-var-declaration-per-line, no-unused-vars, no-return-assign, comma-dangle, quote-props, no-unused-expressions, no-sequences, max-len */ import $ from 'jquery'; @@ -95,7 +95,7 @@ export default class ImageFile { }); return [maxWidth, maxHeight]; } - // eslint-disable-next-line + views = { 'two-up': function() { return $('.two-up.view .wrap', this.file).each((function(_this) { @@ -122,7 +122,7 @@ export default class ImageFile { return $('.swipe.view', this.file).each((function(_this) { return function(index, view) { var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref; - ref = _this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1]; + ref = _this.prepareFrames(view), [maxWidth, maxHeight] = ref; $swipeFrame = $('.swipe-frame', view); $swipeWrap = $('.swipe-wrap', view); $swipeBar = $('.swipe-bar', view); @@ -159,7 +159,7 @@ export default class ImageFile { return $('.onion-skin.view', this.file).each((function(_this) { return function(index, view) { var $frame, $track, $dragger, $frameAdded, framePadding, ref, dragging = false; - ref = _this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1]; + ref = _this.prepareFrames(view), [maxWidth, maxHeight] = ref; $frame = $('.onion-skin-frame', view); $frameAdded = $('.frame.added', view); $track = $('.drag-track', view); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 24d63b99a29..95c4be64d35 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -77,9 +77,9 @@ <div class="content-list pipelines"> <loading-icon + v-if="isLoading" :label="s__('Pipelines|Loading Pipelines')" size="3" - v-if="isLoading" class="prepend-top-20" /> @@ -91,8 +91,8 @@ /> <div - class="table-holder" v-else-if="shouldRenderTable" + class="table-holder" > <pipelines-table-component :pipelines="state.pipelines" diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 7e2a3573f81..9a3ea7a55b6 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -45,7 +45,7 @@ export default class CommitsList { this.content.fadeTo('fast', 1.0); // Change url so if user reload a page - search results are saved - history.replaceState({ + window.history.replaceState({ page: commitsUrl, }, document.title, commitsUrl); }) diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index ffe15f02f2e..a252036d657 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, object-shorthand, comma-dangle, prefer-arrow-callback, no-else-return, newline-per-chained-call, wrap-iife, max-len */ +/* eslint-disable func-names, one-var, no-var, one-var-declaration-per-line, object-shorthand, no-else-return, max-len */ import $ from 'jquery'; import { __ } from './locale'; diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 09d490106df..02aa507ba03 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -66,8 +66,14 @@ export default class CreateMergeRequestDropdown { } bindEvents() { - this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); - this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + 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)); @@ -77,7 +83,8 @@ export default class CreateMergeRequestDropdown { checkAbilityToCreateBranch() { this.setUnavailableButtonState(); - axios.get(this.canCreatePath) + axios + .get(this.canCreatePath) .then(({ data }) => { this.setUnavailableButtonState(false); @@ -105,7 +112,8 @@ export default class CreateMergeRequestDropdown { createBranch() { this.isCreatingBranch = true; - return axios.post(this.createBranchPath) + return axios + .post(this.createBranchPath) .then(({ data }) => { this.branchCreated = true; window.location.href = data.url; @@ -116,7 +124,8 @@ export default class CreateMergeRequestDropdown { createMergeRequest() { this.isCreatingMergeRequest = true; - return axios.post(this.createMrPath) + return axios + .post(this.createMrPath) .then(({ data }) => { this.mergeRequestCreated = true; window.location.href = data.url; @@ -195,7 +204,8 @@ export default class CreateMergeRequestDropdown { getRef(ref, target = 'all') { if (!ref) return false; - return axios.get(this.refsPath + ref) + return axios + .get(`${this.refsPath}${encodeURIComponent(ref)}`) .then(({ data }) => { const branches = data[Object.keys(data)[0]]; const tags = data[Object.keys(data)[1]]; @@ -204,7 +214,8 @@ export default class CreateMergeRequestDropdown { if (target === 'branch') { result = CreateMergeRequestDropdown.findByValue(branches, ref); } else { - result = CreateMergeRequestDropdown.findByValue(branches, ref, true) || + result = + CreateMergeRequestDropdown.findByValue(branches, ref, true) || CreateMergeRequestDropdown.findByValue(tags, ref, true); this.suggestedRef = result; } @@ -255,11 +266,13 @@ export default class CreateMergeRequestDropdown { } isBusy() { - return this.isCreatingMergeRequest || + return ( + this.isCreatingMergeRequest || this.mergeRequestCreated || this.isCreatingBranch || this.branchCreated || - this.isGettingRef; + this.isGettingRef + ); } onChangeInput(event) { @@ -268,10 +281,11 @@ export default class CreateMergeRequestDropdown { if (event.target === this.branchInput) { target = 'branch'; - value = this.branchInput.value; + ({ value } = this.branchInput); } else if (event.target === this.refInput) { target = 'ref'; - value = event.target.value.slice(0, event.target.selectionStart) + + value = + event.target.value.slice(0, event.target.selectionStart) + event.target.value.slice(event.target.selectionEnd); } else { return false; @@ -352,7 +366,7 @@ export default class CreateMergeRequestDropdown { 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']; + const messageClasses = ['text-muted', 'text-danger', 'text-success']; inputClasses.forEach(cssClass => input.classList.remove(cssClass)); messageClasses.forEach(cssClass => message.classList.remove(cssClass)); @@ -379,7 +393,7 @@ export default class CreateMergeRequestDropdown { this.removeMessage(target); input.classList.add('gl-field-success-outline'); - message.classList.add('gl-field-success-message'); + message.classList.add('text-success'); message.textContent = sprintf(__('%{text} is available'), { text }); message.style.display = 'inline-block'; } @@ -389,18 +403,19 @@ export default class CreateMergeRequestDropdown { const text = target === 'branch' ? __('branch name') : __('source'); this.removeMessage(target); - message.classList.add('gl-field-hint'); + message.classList.add('text-muted'); 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'); + 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.classList.add('text-danger'); message.textContent = text; message.style.display = 'inline-block'; } @@ -459,11 +474,15 @@ export default class CreateMergeRequestDropdown { // target - 'branch' or 'ref' // ref - string - the new value to use as branch or ref updateCreatePaths(target, ref) { - const pathReplacement = `$1${ref}`; + const pathReplacement = `$1${encodeURIComponent(ref)}`; - this.createBranchPath = this.createBranchPath.replace(this.regexps[target].createBranchPath, - pathReplacement); - this.createMrPath = this.createMrPath.replace(this.regexps[target].createMrPath, - pathReplacement); + this.createBranchPath = this.createBranchPath.replace( + this.regexps[target].createBranchPath, + pathReplacement, + ); + this.createMrPath = this.createMrPath.replace( + this.regexps[target].createMrPath, + pathReplacement, + ); } } diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue index 3204b8dd8e7..410d4873e55 100644 --- a/app/assets/javascripts/cycle_analytics/components/banner.vue +++ b/app/assets/javascripts/cycle_analytics/components/banner.vue @@ -23,9 +23,9 @@ <template> <div class="landing content-block"> <button + :aria-label="__('Dismiss Cycle Analytics introduction box')" class="js-ca-dismiss-button dismiss-button" type="button" - :aria-label="__('Dismiss Cycle Analytics introduction box')" @click="dismissOverviewDialog" > <i diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue index 5be17081b58..b626b187651 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue @@ -19,14 +19,14 @@ class="events-info float-right" > <i - class="fa fa-warning" v-tooltip - aria-hidden="true" :title="n__( 'Limited to showing %d event at most', 'Limited to showing %d events at most', 50 )" + class="fa fa-warning" + aria-hidden="true" data-placement="top" > </i> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_component.vue index 907638d798a..312fe75dde4 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_component.vue @@ -38,8 +38,8 @@ <user-avatar-image :img-src="issue.author.avatarUrl"/> <h5 class="item-title issue-title"> <a - class="issue-title" :href="issue.url" + class="issue-title" > {{ issue.title }} </a> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue index 34aa04083e6..d4735d030fc 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue @@ -74,12 +74,12 @@ </template> <template v-else> <span - class="merge-request-branch" v-if="mergeRequest.branch" + class="merge-request-branch" > <icon - name="fork" :size="16" + name="fork" /> <a :href="mergeRequest.branch.url"> {{ mergeRequest.branch.name }} diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue index 92f2a95a66a..22637485c01 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.vue @@ -38,8 +38,8 @@ <ul class="stage-event-list"> <li v-for="(build, i) in items" - class="stage-event-item item-build-component" :key="i" + class="stage-event-item item-build-component" > <div class="item-details"> <!-- FIXME: Pass an alt attribute here for accessibility --> @@ -52,8 +52,8 @@ #{{ build.id }} </a> <icon - name="fork" :size="16" + name="fork" /> <a :href="build.branch.url" diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue index b84bb6ed792..a0796f299e7 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.vue @@ -64,8 +64,8 @@ #{{ build.id }} </a> <icon - name="fork" :size="16" + name="fork" /> <a :href="build.branch.url" diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 67dda0e29cb..7399fc97d45 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -40,9 +40,9 @@ export default { <template> <button - class="btn" :class="[{ disabled: isLoading }, btnCssClass]" :disabled="isLoading" + class="btn" @click="doAction"> <slot></slot> <loading-icon diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index c41fe55db63..d91e4809126 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -98,7 +98,7 @@ export default { }, disableKey(deployKey, callback) { // eslint-disable-next-line no-alert - if (confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?'))) { + if (window.confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?'))) { this.service .disableKey(deployKey.id) .then(this.fetchKeys) @@ -116,8 +116,8 @@ export default { <div class="append-bottom-default deploy-keys"> <loading-icon v-if="isLoading && !hasKeys" - size="2" :label="s__('DeployKeys|Loading deploy keys')" + size="2" /> <template v-else-if="hasKeys"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> @@ -138,16 +138,16 @@ export default { <navigation-tabs :tabs="tabs" - @onChangeTab="onChangeTab" scope="deployKeys" + @onChangeTab="onChangeTab" /> </div> <keys-panel - class="qa-project-deploy-keys" :project-id="projectId" :keys="keys[currentTab]" :store="store" :endpoint="endpoint" + class="qa-project-deploy-keys" /> </template> </div> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 6c2af7fa768..f66ca070445 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -135,9 +135,9 @@ export default { <div class="table-mobile-content deploy-project-list"> <template v-if="projects.length > 0"> <a - class="label deploy-project-label" - :title="projectTooltipTitle(firstProject)" v-tooltip + :title="projectTooltipTitle(firstProject)" + class="label deploy-project-label" > <span> {{ firstProject.project.full_name }} @@ -145,22 +145,22 @@ export default { <icon :name="firstProject.can_push ? 'lock-open' : 'lock'"/> </a> <a + v-tooltip v-if="isExpandable" + :title="restProjectsTooltip" class="label deploy-project-label" @click="toggleExpanded" - :title="restProjectsTooltip" - v-tooltip > <span>{{ restProjectsLabel }}</span> </a> <a - v-else-if="isExpanded" + v-tooltip v-for="deployKeysProject in restProjects" + v-else-if="isExpanded" :key="deployKeysProject.project.full_path" - class="label deploy-project-label" :href="deployKeysProject.project.full_path" :title="projectTooltipTitle(deployKeysProject)" - v-tooltip + class="label deploy-project-label" > <span> {{ deployKeysProject.project.full_name }} @@ -181,8 +181,8 @@ export default { </div> <div class="table-mobile-content text-secondary key-created-at"> <span - :title="tooltipTitle(deployKey.created_at)" - v-tooltip> + v-tooltip + :title="tooltipTitle(deployKey.created_at)"> <icon name="calendar"/> <span>{{ timeFormated(deployKey.created_at) }}</span> </span> @@ -198,34 +198,34 @@ export default { {{ __('Enable') }} </action-btn> <a + v-tooltip v-if="deployKey.can_edit" - class="btn btn-default text-secondary" :href="editDeployKeyPath" :title="__('Edit')" + class="btn btn-default text-secondary" data-container="body" - v-tooltip > <icon name="pencil"/> </a> <action-btn + v-tooltip v-if="isRemovable" :deploy-key="deployKey" + :title="__('Remove')" btn-css-class="btn-danger" type="remove" - :title="__('Remove')" data-container="body" - v-tooltip > <icon name="remove"/> </action-btn> <action-btn + v-tooltip v-else-if="isEnabled" :deploy-key="deployKey" + :title="__('Disable')" btn-css-class="btn-warning" type="disable" - :title="__('Disable')" data-container="body" - v-tooltip > <icon name="cancel"/> </action-btn> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index 3b146c7389a..2f057ca29f6 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -59,8 +59,8 @@ export default { /> </template> <div - class="settings-message text-center" v-else + class="settings-message text-center" > {{ s__('DeployKeys|No deploy keys found. Create one with the form above.') }} </div> diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index d1260ff5373..ed24d1775f4 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -6,7 +6,10 @@ import Vue from 'vue'; const CommentAndResolveBtn = Vue.extend({ props: { - discussionId: String, + discussionId: { + type: String, + required: true, + }, }, data() { return { diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index fe9b0795609..5528d2a542b 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -7,7 +7,15 @@ import Notes from '../../notes'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const DiffNoteAvatars = Vue.extend({ - props: ['discussionId'], + components: { + userAvatarImage, + }, + props: { + discussionId: { + type: String, + required: true, + }, + }, data() { return { isVisible: false, @@ -17,77 +25,6 @@ const DiffNoteAvatars = Vue.extend({ collapseIcon, }; }, - components: { - userAvatarImage, - }, - template: ` - <div class="diff-comment-avatar-holders" - :class="discussionClassName" - v-show="notesCount !== 0"> - <div v-if="!isVisible"> - <!-- FIXME: Pass an alt attribute here for accessibility --> - <user-avatar-image - v-for="note in notesSubset" - :key="note.id" - class="diff-comment-avatar js-diff-comment-avatar" - @click.native="clickedAvatar($event)" - :img-src="note.authorAvatar" - :tooltip-text="getTooltipText(note)" - :data-line-type="lineType" - :size="19" - data-html="true" - /> - <span v-if="notesCount > shownAvatars" - class="diff-comments-more-count has-tooltip js-diff-comment-avatar" - data-container="body" - data-placement="top" - ref="extraComments" - role="button" - :data-line-type="lineType" - :title="extraNotesTitle" - @click="clickedAvatar($event)">{{ moreText }}</span> - </div> - <button class="diff-notes-collapse js-diff-comment-avatar" - type="button" - aria-label="Show comments" - :data-line-type="lineType" - @click="clickedAvatar($event)" - v-if="isVisible" - v-html="collapseIcon"> - </button> - </div> - `, - mounted() { - this.$nextTick(() => { - this.addNoCommentClass(); - this.setDiscussionVisible(); - - this.lineType = $(this.$el).closest('.diff-line-num').hasClass('old_line') ? 'old' : 'new'; - }); - - $(document).on('toggle.comments', () => { - this.$nextTick(() => { - this.setDiscussionVisible(); - }); - }); - }, - beforeDestroy() { - this.addNoCommentClass(); - $(document).off('toggle.comments'); - }, - watch: { - storeState: { - handler() { - this.$nextTick(() => { - $('.has-tooltip', this.$el).tooltip('_fixTitle'); - - // We need to add/remove a class to an element that is outside the Vue instance - this.addNoCommentClass(); - }); - }, - deep: true, - }, - }, computed: { discussionClassName() { return `js-diff-avatars-${this.discussionId}`; @@ -128,6 +65,37 @@ const DiffNoteAvatars = Vue.extend({ return `${plusSign}${this.notesCount - this.shownAvatars}`; }, }, + watch: { + storeState: { + handler() { + this.$nextTick(() => { + $('.has-tooltip', this.$el).tooltip('_fixTitle'); + + // We need to add/remove a class to an element that is outside the Vue instance + this.addNoCommentClass(); + }); + }, + deep: true, + }, + }, + mounted() { + this.$nextTick(() => { + this.addNoCommentClass(); + this.setDiscussionVisible(); + + this.lineType = $(this.$el).closest('.diff-line-num').hasClass('old_line') ? 'old' : 'new'; + }); + + $(document).on('toggle.comments', () => { + this.$nextTick(() => { + this.setDiscussionVisible(); + }); + }); + }, + beforeDestroy() { + this.addNoCommentClass(); + $(document).off('toggle.comments'); + }, methods: { clickedAvatar(e) { Notes.instance.onAddDiffNote(e); @@ -143,7 +111,7 @@ const DiffNoteAvatars = Vue.extend({ }); }, addNoCommentClass() { - const notesCount = this.notesCount; + const { notesCount } = this; $(this.$el).closest('.js-avatar-container') .toggleClass('no-comment-btn', notesCount > 0) @@ -164,6 +132,43 @@ const DiffNoteAvatars = Vue.extend({ return `${note.authorName}: ${note.noteTruncated}`; }, }, + template: ` + <div class="diff-comment-avatar-holders" + :class="discussionClassName" + v-show="notesCount !== 0"> + <div v-if="!isVisible"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image + v-for="note in notesSubset" + :key="note.id" + class="diff-comment-avatar js-diff-comment-avatar" + @click.native="clickedAvatar($event)" + :img-src="note.authorAvatar" + :tooltip-text="getTooltipText(note)" + :data-line-type="lineType" + :size="19" + data-html="true" + /> + <span v-if="notesCount > shownAvatars" + class="diff-comments-more-count has-tooltip js-diff-comment-avatar" + data-container="body" + data-placement="top" + ref="extraComments" + role="button" + :data-line-type="lineType" + :title="extraNotesTitle" + @click="clickedAvatar($event)">{{ moreText }}</span> + </div> + <button class="diff-notes-collapse js-diff-comment-avatar" + type="button" + aria-label="Show comments" + :data-line-type="lineType" + @click="clickedAvatar($event)" + v-if="isVisible" + v-html="collapseIcon"> + </button> + </div> + `, }); Vue.component('diff-note-avatars', DiffNoteAvatars); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 8f9186dfb9a..2b893e35b6d 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -1,16 +1,18 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, guard-for-in, no-restricted-syntax, one-var, space-before-function-paren, no-lonely-if, no-continue, brace-style, max-len, quotes */ -/* global DiscussionMixins */ +/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue, brace-style, max-len, quotes */ /* global CommentsStore */ import $ from 'jquery'; import Vue from 'vue'; -import '../mixins/discussion'; +import DiscussionMixins from '../mixins/discussion'; const JumpToDiscussion = Vue.extend({ mixins: [DiscussionMixins], props: { - discussionId: String + discussionId: { + type: String, + required: true, + }, }, data: function () { return { @@ -52,6 +54,9 @@ const JumpToDiscussion = Vue.extend({ return lastId; } }, + created() { + this.discussion = this.discussions[this.discussionId]; + }, methods: { jumpToNextUnresolvedDiscussion: function () { let discussionsSelector; @@ -68,7 +73,7 @@ const JumpToDiscussion = Vue.extend({ }).toArray(); }; - const discussions = this.discussions; + const { discussions } = this; if (activeTab === 'diffs') { discussionsSelector = '.diffs .notes[data-discussion-id]'; @@ -202,9 +207,6 @@ const JumpToDiscussion = Vue.extend({ }); } }, - created() { - this.discussion = this.discussions[this.discussionId]; - }, }); Vue.component('jump-to-discussion', JumpToDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 8d66417abac..a69b34b0db8 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -1,4 +1,3 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, max-len */ /* global CommentsStore */ /* global ResolveService */ @@ -8,113 +7,135 @@ import Flash from '../../flash'; const ResolveBtn = Vue.extend({ props: { - noteId: Number, - discussionId: String, - resolved: Boolean, - canResolve: Boolean, - resolvedBy: String, - authorName: String, - authorAvatar: String, - noteTruncated: String, + noteId: { + type: Number, + required: true, + }, + discussionId: { + type: String, + required: true, + }, + resolved: { + type: Boolean, + required: true, + }, + canResolve: { + type: Boolean, + required: true, + }, + resolvedBy: { + type: String, + required: true, + }, + authorName: { + type: String, + required: true, + }, + authorAvatar: { + type: String, + required: true, + }, + noteTruncated: { + type: String, + required: true, + }, }, - data: function () { + data() { return { discussions: CommentsStore.state, - loading: false + loading: false, }; }, - watch: { - 'discussions': { - handler: 'updateTooltip', - deep: true - } - }, computed: { - discussion: function () { + discussion() { return this.discussions[this.discussionId]; }, - note: function () { + note() { return this.discussion ? this.discussion.getNote(this.noteId) : {}; }, - buttonText: function () { + buttonText() { if (this.isResolved) { return `Resolved by ${this.resolvedByName}`; } else if (this.canResolve) { return 'Mark as resolved'; - } else { - return 'Unable to resolve'; } + + return 'Unable to resolve'; }, - isResolved: function () { + isResolved() { if (this.note) { return this.note.resolved; - } else { - return false; } + + return false; }, - resolvedByName: function () { + resolvedByName() { return this.note.resolved_by; }, }, + watch: { + discussions: { + handler: 'updateTooltip', + deep: true, + }, + }, + mounted() { + $(this.$refs.button).tooltip({ + container: 'body', + }); + }, + beforeDestroy() { + CommentsStore.delete(this.discussionId, this.noteId); + }, + created() { + CommentsStore.create({ + discussionId: this.discussionId, + noteId: this.noteId, + canResolve: this.canResolve, + resolved: this.resolved, + resolvedBy: this.resolvedBy, + authorName: this.authorName, + authorAvatar: this.authorAvatar, + noteTruncated: this.noteTruncated, + }); + }, methods: { - updateTooltip: function () { + updateTooltip() { this.$nextTick(() => { $(this.$refs.button) .tooltip('hide') .tooltip('_fixTitle'); }); }, - resolve: function () { + resolve() { if (!this.canResolve) return; let promise; this.loading = true; if (this.isResolved) { - promise = ResolveService - .unresolve(this.noteId); + promise = ResolveService.unresolve(this.noteId); } else { - promise = ResolveService - .resolve(this.noteId); + promise = ResolveService.resolve(this.noteId); } promise .then(resp => resp.json()) - .then((data) => { + .then(data => { this.loading = false; - const resolved_by = data ? data.resolved_by : null; + const resolvedBy = data ? data.resolved_by : null; - CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); + CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolvedBy); this.discussion.updateHeadline(data); gl.mrWidget.checkStatus(); - document.dispatchEvent(new CustomEvent('refreshVueNotes')); - this.updateTooltip(); }) - .catch(() => new Flash('An error occurred when trying to resolve a comment. Please try again.')); - } - }, - mounted: function () { - $(this.$refs.button).tooltip({ - container: 'body' - }); - }, - beforeDestroy: function () { - CommentsStore.delete(this.discussionId, this.noteId); + .catch( + () => new Flash('An error occurred when trying to resolve a comment. Please try again.'), + ); + }, }, - created: function () { - CommentsStore.create({ - discussionId: this.discussionId, - noteId: this.noteId, - canResolve: this.canResolve, - resolved: this.resolved, - resolvedBy: this.resolvedBy, - authorName: this.authorName, - authorAvatar: this.authorAvatar, - noteTruncated: this.noteTruncated, - }); - } }); Vue.component('resolve-btn', ResolveBtn); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js index fe7cf8f5fc1..e2683e09f40 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_count.js +++ b/app/assets/javascripts/diff_notes/components/resolve_count.js @@ -1,15 +1,17 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, no-param-reassign */ -/* global DiscussionMixins */ +/* eslint-disable comma-dangle, object-shorthand, func-names */ /* global CommentsStore */ import Vue from 'vue'; -import '../mixins/discussion'; +import DiscussionMixins from '../mixins/discussion'; window.ResolveCount = Vue.extend({ mixins: [DiscussionMixins], props: { - loggedOut: Boolean + loggedOut: { + type: Boolean, + required: true, + }, }, data: function () { return { diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js index 6a036e96171..5ed13488788 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js @@ -1,4 +1,4 @@ -/* eslint-disable object-shorthand, func-names, space-before-function-paren, comma-dangle, no-else-return, quotes, max-len */ +/* eslint-disable object-shorthand, func-names, comma-dangle, no-else-return, quotes */ /* global CommentsStore */ /* global ResolveService */ @@ -6,9 +6,18 @@ import Vue from 'vue'; const ResolveDiscussionBtn = Vue.extend({ props: { - discussionId: String, - mergeRequestId: Number, - canResolve: Boolean, + discussionId: { + type: String, + required: true, + }, + mergeRequestId: { + type: Number, + required: true, + }, + canResolve: { + type: Boolean, + required: true, + }, }, data: function() { return { @@ -45,16 +54,16 @@ const ResolveDiscussionBtn = Vue.extend({ } } }, + created: function () { + CommentsStore.createDiscussion(this.discussionId, this.canResolve); + + this.discussion = CommentsStore.state[this.discussionId]; + }, methods: { resolve: function () { ResolveService.toggleResolveForDiscussion(this.mergeRequestId, this.discussionId); } }, - created: function () { - CommentsStore.createDiscussion(this.discussionId, this.canResolve); - - this.discussion = CommentsStore.state[this.discussionId]; - } }); Vue.component('resolve-discussion-btn', ResolveDiscussionBtn); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index e17daec6a92..7dcf3594471 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -1,5 +1,4 @@ -/* eslint-disable func-names, comma-dangle, new-cap, no-new, max-len */ -/* global ResolveCount */ +/* eslint-disable func-names, new-cap */ import $ from 'jquery'; import Vue from 'vue'; @@ -15,12 +14,13 @@ import './components/resolve_count'; import './components/resolve_discussion_btn'; import './components/diff_note_avatars'; import './components/new_issue_for_discussion'; -import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils'; export default () => { - const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); - const projectPath = projectPathHolder.dataset.projectPath; - const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; + const projectPathHolder = + document.querySelector('.merge-request') || document.querySelector('.commit-box'); + const { projectPath } = projectPathHolder.dataset; + const COMPONENT_SELECTOR = + 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; window.gl = window.gl || {}; window.gl.diffNoteApps = {}; @@ -28,9 +28,9 @@ export default () => { window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); gl.diffNotesCompileComponents = () => { - $('diff-note-avatars').each(function () { + $('diff-note-avatars').each(function() { const tmp = Vue.extend({ - template: $(this).get(0).outerHTML + template: $(this).get(0).outerHTML, }); const tmpApp = new tmp().$mount(); @@ -41,12 +41,12 @@ export default () => { }); }); - const $components = $(COMPONENT_SELECTOR).filter(function () { + const $components = $(COMPONENT_SELECTOR).filter(function() { return $(this).closest('resolve-count').length !== 1; }); if ($components) { - $components.each(function () { + $components.each(function() { const $this = $(this); const noteId = $this.attr(':note-id'); const discussionId = $this.attr(':discussion-id'); @@ -54,7 +54,7 @@ export default () => { if ($this.is('comment-and-resolve-btn') && !discussionId) return; const tmp = Vue.extend({ - template: $this.get(0).outerHTML + template: $this.get(0).outerHTML, }); const tmpApp = new tmp().$mount(); @@ -69,14 +69,5 @@ export default () => { gl.diffNotesCompileComponents(); - if (!hasVueMRDiscussionsCookie()) { - new Vue({ - el: '#resolve-count-app', - components: { - 'resolve-count': ResolveCount - }, - }); - } - $(window).trigger('resize.nav'); }; diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js b/app/assets/javascripts/diff_notes/mixins/discussion.js index 36c4abf02cf..ef35b589e58 100644 --- a/app/assets/javascripts/diff_notes/mixins/discussion.js +++ b/app/assets/javascripts/diff_notes/mixins/discussion.js @@ -1,6 +1,6 @@ -/* eslint-disable object-shorthand, func-names, guard-for-in, no-restricted-syntax, comma-dangle, no-param-reassign, max-len */ +/* eslint-disable object-shorthand, func-names, guard-for-in, no-restricted-syntax, comma-dangle, */ -window.DiscussionMixins = { +const DiscussionMixins = { computed: { discussionCount: function () { return Object.keys(this.discussions).length; @@ -33,3 +33,5 @@ window.DiscussionMixins = { } } }; + +export default DiscussionMixins; diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js index c97c559dd14..787e6d8855f 100644 --- a/app/assets/javascripts/diff_notes/models/discussion.js +++ b/app/assets/javascripts/diff_notes/models/discussion.js @@ -1,4 +1,4 @@ -/* eslint-disable space-before-function-paren, camelcase, guard-for-in, no-restricted-syntax, no-unused-vars, max-len */ +/* eslint-disable camelcase, guard-for-in, no-restricted-syntax */ /* global NoteModel */ import $ from 'jquery'; diff --git a/app/assets/javascripts/diff_notes/models/note.js b/app/assets/javascripts/diff_notes/models/note.js index 04465aa507e..825a69deeec 100644 --- a/app/assets/javascripts/diff_notes/models/note.js +++ b/app/assets/javascripts/diff_notes/models/note.js @@ -1,5 +1,3 @@ -/* eslint-disable camelcase, no-unused-vars */ - class NoteModel { constructor(discussionId, noteObj) { this.discussionId = discussionId; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index d16f9297de1..0b3568e432d 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -8,8 +8,12 @@ window.gl = window.gl || {}; class ResolveServiceClass { constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`); - this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`); + this.noteResource = Vue.resource( + `${root}/notes{/noteId}/resolve?html=true`, + ); + this.discussionResource = Vue.resource( + `${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`, + ); } resolve(noteId) { @@ -33,7 +37,7 @@ class ResolveServiceClass { promise .then(resp => resp.json()) - .then((data) => { + .then(data => { discussion.loading = false; const resolvedBy = data ? data.resolved_by : null; @@ -45,9 +49,13 @@ class ResolveServiceClass { if (gl.mrWidget) gl.mrWidget.checkStatus(); discussion.updateHeadline(data); - document.dispatchEvent(new CustomEvent('refreshVueNotes')); }) - .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.')); + .catch( + () => + new Flash( + 'An error occurred when trying to resolve a discussion. Please try again.', + ), + ); } resolveAll(mergeRequestId, discussionId) { @@ -55,10 +63,13 @@ class ResolveServiceClass { discussion.loading = true; - return this.discussionResource.save({ - mergeRequestId, - discussionId, - }, {}); + return this.discussionResource.save( + { + mergeRequestId, + discussionId, + }, + {}, + ); } unResolveAll(mergeRequestId, discussionId) { @@ -66,10 +77,13 @@ class ResolveServiceClass { discussion.loading = true; - return this.discussionResource.delete({ - mergeRequestId, - discussionId, - }, {}); + return this.discussionResource.delete( + { + mergeRequestId, + discussionId, + }, + {}, + ); } } diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js index d802db7d3af..d7da7d974f3 100644 --- a/app/assets/javascripts/diff_notes/stores/comments.js +++ b/app/assets/javascripts/diff_notes/stores/comments.js @@ -1,4 +1,4 @@ -/* eslint-disable object-shorthand, func-names, camelcase, no-restricted-syntax, guard-for-in, comma-dangle, max-len, no-param-reassign */ +/* eslint-disable object-shorthand, func-names, camelcase, no-restricted-syntax, guard-for-in, comma-dangle, max-len */ /* global DiscussionModel */ import Vue from 'vue'; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue new file mode 100644 index 00000000000..eb0985e5603 --- /dev/null +++ b/app/assets/javascripts/diffs/components/app.vue @@ -0,0 +1,216 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import eventHub from '../../notes/event_hub'; +import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; +import CompareVersions from './compare_versions.vue'; +import ChangedFiles from './changed_files.vue'; +import DiffFile from './diff_file.vue'; +import NoChanges from './no_changes.vue'; +import HiddenFilesWarning from './hidden_files_warning.vue'; + +export default { + name: 'DiffsApp', + components: { + Icon, + LoadingIcon, + CompareVersions, + ChangedFiles, + DiffFile, + NoChanges, + HiddenFilesWarning, + }, + props: { + endpoint: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + shouldShow: { + type: Boolean, + required: false, + default: false, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + activeFile: '', + }; + }, + computed: { + ...mapState({ + isLoading: state => state.diffs.isLoading, + diffFiles: state => state.diffs.diffFiles, + diffViewType: state => state.diffs.diffViewType, + mergeRequestDiffs: state => state.diffs.mergeRequestDiffs, + mergeRequestDiff: state => state.diffs.mergeRequestDiff, + latestVersionPath: state => state.diffs.latestVersionPath, + startVersion: state => state.diffs.startVersion, + commit: state => state.diffs.commit, + targetBranchName: state => state.diffs.targetBranchName, + renderOverflowWarning: state => state.diffs.renderOverflowWarning, + numTotalFiles: state => state.diffs.realSize, + numVisibleFiles: state => state.diffs.size, + plainDiffPath: state => state.diffs.plainDiffPath, + emailPatchPath: state => state.diffs.emailPatchPath, + }), + ...mapGetters(['isParallelView', 'isNotesFetched']), + targetBranch() { + return { + branchName: this.targetBranchName, + versionIndex: -1, + path: '', + }; + }, + notAllCommentsDisplayed() { + if (this.commit) { + return __('Only comments from the following commit are shown below'); + } else if (this.startVersion) { + return __( + "Not all comments are displayed because you're comparing two versions of the diff.", + ); + } + return __( + "Not all comments are displayed because you're viewing an old version of the diff.", + ); + }, + showLatestVersion() { + if (this.commit) { + return __('Show latest version of the diff'); + } + return __('Show latest version'); + }, + }, + watch: { + diffViewType() { + this.adjustView(); + }, + shouldShow() { + // When the shouldShow property changed to true, the route is rendered for the first time + // and if we have the isLoading as true this means we didn't fetch the data + if (this.isLoading) { + this.fetchData(); + } + + this.adjustView(); + }, + }, + mounted() { + this.setBaseConfig({ endpoint: this.endpoint, projectPath: this.projectPath }); + + if (this.shouldShow) { + this.fetchData(); + } + }, + created() { + this.adjustView(); + }, + methods: { + ...mapActions(['setBaseConfig', 'fetchDiffFiles']), + fetchData() { + this.fetchDiffFiles().catch(() => { + createFlash(__('Something went wrong on our end. Please try again!')); + }); + + if (!this.isNotesFetched) { + eventHub.$emit('fetchNotesData'); + } + }, + setActive(filePath) { + this.activeFile = filePath; + }, + unsetActive(filePath) { + if (this.activeFile === filePath) { + this.activeFile = ''; + } + }, + adjustView() { + if (this.shouldShow && this.isParallelView) { + window.mrTabs.expandViewContainer(); + } else { + window.mrTabs.resetViewContainer(); + } + }, + }, +}; +</script> + +<template> + <div v-show="shouldShow"> + <div + v-if="isLoading" + class="loading" + > + <loading-icon /> + </div> + <div + v-else + id="diffs" + :class="{ active: shouldShow }" + class="diffs tab-pane" + > + <compare-versions + v-if="!commit && mergeRequestDiffs.length > 1" + :merge-request-diffs="mergeRequestDiffs" + :merge-request-diff="mergeRequestDiff" + :start-version="startVersion" + :target-branch="targetBranch" + /> + + <hidden-files-warning + v-if="renderOverflowWarning" + :visible="numVisibleFiles" + :total="numTotalFiles" + :plain-diff-path="plainDiffPath" + :email-patch-path="emailPatchPath" + /> + + <div + v-if="commit || startVersion || (mergeRequestDiff && !mergeRequestDiff.latest)" + class="mr-version-controls" + > + <div class="content-block comments-disabled-notif clearfix"> + <i class="fa fa-info-circle"></i> + {{ notAllCommentsDisplayed }} + <div class="pull-right"> + <a + :href="latestVersionPath" + class="btn btn-sm" + > + {{ showLatestVersion }} + </a> + </div> + </div> + </div> + + <changed-files + :diff-files="diffFiles" + :active-file="activeFile" + /> + + <div + v-if="diffFiles.length > 0" + class="files" + > + <diff-file + v-for="file in diffFiles" + :key="file.newPath" + :file="file" + :current-user="currentUser" + @setActive="setActive(file.filePath)" + @unsetActive="unsetActive(file.filePath)" + /> + </div> + <no-changes v-else /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/changed_files.vue b/app/assets/javascripts/diffs/components/changed_files.vue new file mode 100644 index 00000000000..c5ef9fefc2f --- /dev/null +++ b/app/assets/javascripts/diffs/components/changed_files.vue @@ -0,0 +1,184 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { pluralize } from '~/lib/utils/text_utility'; +import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; +import { contentTop } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import ChangedFilesDropdown from './changed_files_dropdown.vue'; +import changedFilesMixin from '../mixins/changed_files'; + +export default { + components: { + Icon, + ChangedFilesDropdown, + ClipboardButton, + }, + mixins: [changedFilesMixin], + props: { + activeFile: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isStuck: false, + maxWidth: 'auto', + offsetTop: 0, + }; + }, + computed: { + ...mapGetters(['isInlineView', 'isParallelView', 'areAllFilesCollapsed']), + sumAddedLines() { + return this.sumValues('addedLines'); + }, + sumRemovedLines() { + return this.sumValues('removedLines'); + }, + whitespaceVisible() { + return !getParameterValues('w')[0]; + }, + toggleWhitespaceText() { + if (this.whitespaceVisible) { + return __('Hide whitespace changes'); + } + return __('Show whitespace changes'); + }, + toggleWhitespacePath() { + if (this.whitespaceVisible) { + return mergeUrlParams({ w: 1 }, window.location.href); + } + + return mergeUrlParams({ w: 0 }, window.location.href); + }, + top() { + return `${this.offsetTop}px`; + }, + }, + created() { + document.addEventListener('scroll', this.handleScroll); + this.offsetTop = contentTop(); + }, + beforeDestroy() { + document.removeEventListener('scroll', this.handleScroll); + }, + methods: { + ...mapActions(['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']), + pluralize, + handleScroll() { + if (!this.updating) { + requestAnimationFrame(this.updateIsStuck); + this.updating = true; + } + }, + updateIsStuck() { + if (!this.$refs.wrapper) { + return; + } + + const scrollPosition = window.scrollY; + + this.isStuck = scrollPosition + this.offsetTop >= this.$refs.placeholder.offsetTop; + this.updating = false; + }, + sumValues(key) { + return this.diffFiles.reduce((total, file) => total + file[key], 0); + }, + }, +}; +</script> + +<template> + <span> + <div ref="placeholder"></div> + <div + ref="wrapper" + :style="{ top }" + :class="{'is-stuck': isStuck}" + class="content-block oneline-block diff-files-changed diff-files-changed-merge-request + files-changed js-diff-files-changed" + > + <div class="files-changed-inner"> + <div + class="inline-parallel-buttons d-none d-md-block" + > + <a + v-if="areAllFilesCollapsed" + class="btn btn-default" + @click="expandAllFiles" + > + {{ __('Expand all') }} + </a> + <a + :href="toggleWhitespacePath" + class="btn btn-default" + > + {{ toggleWhitespaceText }} + </a> + <div class="btn-group"> + <button + id="inline-diff-btn" + :class="{ active: isInlineView }" + type="button" + class="btn js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </button> + <button + id="parallel-diff-btn" + :class="{ active: isParallelView }" + type="button" + class="btn js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </button> + </div> + </div> + + <div class="commit-stat-summary dropdown"> + <changed-files-dropdown + :diff-files="diffFiles" + /> + + <span + v-show="activeFile" + class="prepend-left-5" + > + <strong class="prepend-right-5"> + {{ truncatedDiffPath(activeFile) }} + </strong> + <clipboard-button + :text="activeFile" + :title="s__('Copy file name to clipboard')" + tooltip-placement="bottom" + tooltip-container="body" + class="btn btn-default btn-transparent btn-clipboard" + /> + </span> + + <span + v-show="!isStuck" + id="diff-stats" + class="diff-stats-additions-deletions-expanded" + > + with + <strong class="cgreen"> + {{ pluralize(`${sumAddedLines} addition`, sumAddedLines) }} + </strong> + and + <strong class="cred"> + {{ pluralize(`${sumRemovedLines} deletion`, sumRemovedLines) }} + </strong> + </span> + </div> + </div> + </div> + </span> +</template> diff --git a/app/assets/javascripts/diffs/components/changed_files_dropdown.vue b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue new file mode 100644 index 00000000000..b38d217fbe3 --- /dev/null +++ b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue @@ -0,0 +1,126 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import changedFilesMixin from '../mixins/changed_files'; + +export default { + components: { + Icon, + }, + mixins: [changedFilesMixin], + data() { + return { + searchText: '', + }; + }, + computed: { + filteredDiffFiles() { + return this.diffFiles.filter(file => + file.filePath.toLowerCase().includes(this.searchText.toLowerCase()), + ); + }, + }, + methods: { + clearSearch() { + this.searchText = ''; + }, + }, +}; +</script> + +<template> + <span> + Showing + <button + class="diff-stats-summary-toggler" + data-toggle="dropdown" + type="button" + aria-expanded="false" + > + <span> + {{ n__('%d changed file', '%d changed files', diffFiles.length) }} + </span> + <icon + :size="8" + name="chevron-down" + /> + </button> + <div class="dropdown-menu diff-file-changes"> + <div class="dropdown-input"> + <input + v-model="searchText" + type="search" + class="dropdown-input-field" + placeholder="Search files" + autocomplete="off" + /> + <i + v-if="searchText.length === 0" + aria-hidden="true" + data-hidden="true" + class="fa fa-search dropdown-input-search"> + </i> + <i + v-else + role="button" + class="fa fa-times dropdown-input-search" + @click="clearSearch" + ></i> + </div> + <div class="dropdown-content"> + <ul> + <li + v-for="diffFile in filteredDiffFiles" + :key="diffFile.name" + > + <a + :href="`#${diffFile.fileHash}`" + :title="diffFile.newPath" + class="diff-changed-file" + > + <icon + :name="fileChangedIcon(diffFile)" + :size="16" + :class="fileChangedClass(diffFile)" + class="diff-file-changed-icon append-right-8" + /> + <span class="diff-changed-file-content append-right-8"> + <strong + v-if="diffFile.blob && diffFile.blob.name" + class="diff-changed-file-name" + > + {{ diffFile.blob.name }} + </strong> + <strong + v-else + class="diff-changed-blank-file-name" + > + {{ s__('Diffs|No file name available') }} + </strong> + <span class="diff-changed-file-path prepend-top-5"> + {{ truncatedDiffPath(diffFile.blob.path) }} + </span> + </span> + <span class="diff-changed-stats"> + <span class="cgreen"> + +{{ diffFile.addedLines }} + </span> + <span class="cred"> + -{{ diffFile.removedLines }} + </span> + </span> + </a> + </li> + + <li + v-show="filteredDiffFiles.length === 0" + class="dropdown-menu-empty-item" + > + <a> + {{ __('No files found') }} + </a> + </li> + </ul> + </div> + </div> + </span> +</template> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue new file mode 100644 index 00000000000..1c9ad8e77f1 --- /dev/null +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -0,0 +1,55 @@ +<script> +import CompareVersionsDropdown from './compare_versions_dropdown.vue'; + +export default { + components: { + CompareVersionsDropdown, + }, + props: { + mergeRequestDiffs: { + type: Array, + required: true, + }, + mergeRequestDiff: { + type: Object, + required: true, + }, + startVersion: { + type: Object, + required: false, + default: null, + }, + targetBranch: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + comparableDiffs() { + return this.mergeRequestDiffs.slice(1); + }, + }, +}; +</script> + +<template> + <div class="mr-version-controls"> + <div class="mr-version-menus-container content-block"> + Changes between + <compare-versions-dropdown + :other-versions="mergeRequestDiffs" + :merge-request-version="mergeRequestDiff" + :show-commit-count="true" + class="mr-version-dropdown" + /> + and + <compare-versions-dropdown + :other-versions="comparableDiffs" + :start-version="startVersion" + :target-branch="targetBranch" + class="mr-version-compare-dropdown" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue new file mode 100644 index 00000000000..96cccb49378 --- /dev/null +++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue @@ -0,0 +1,165 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { n__, __ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + Icon, + TimeAgo, + }, + props: { + otherVersions: { + type: Array, + required: false, + default: () => [], + }, + mergeRequestVersion: { + type: Object, + required: false, + default: null, + }, + startVersion: { + type: Object, + required: false, + default: null, + }, + targetBranch: { + type: Object, + required: false, + default: null, + }, + showCommitCount: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + baseVersion() { + return { + name: 'hii', + versionIndex: -1, + }; + }, + targetVersions() { + if (this.mergeRequestVersion) { + return this.otherVersions; + } + return [...this.otherVersions, this.targetBranch]; + }, + selectedVersionName() { + const selectedVersion = this.startVersion || this.targetBranch || this.mergeRequestVersion; + return this.versionName(selectedVersion); + }, + }, + methods: { + commitsText(version) { + return n__( + `${version.commitsCount} commit,`, + `${version.commitsCount} commits,`, + version.commitsCount, + ); + }, + href(version) { + if (this.showCommitCount) { + return version.versionPath; + } + return version.comparePath; + }, + versionName(version) { + if (this.isLatest(version)) { + return __('latest version'); + } + if (this.targetBranch && (this.isBase(version) || !version)) { + return this.targetBranch.branchName; + } + return `version ${version.versionIndex}`; + }, + isActive(version) { + if (!version) { + return false; + } + + if (this.targetBranch) { + return ( + (this.isBase(version) && !this.startVersion) || + (this.startVersion && this.startVersion.versionIndex === version.versionIndex) + ); + } + + return version.versionIndex === this.mergeRequestVersion.versionIndex; + }, + isBase(version) { + if (!version || !this.targetBranch) { + return false; + } + return version.versionIndex === -1; + }, + isLatest(version) { + return ( + this.mergeRequestVersion && version.versionIndex === this.targetVersions[0].versionIndex + ); + }, + }, +}; +</script> + +<template> + <span class="dropdown inline"> + <a + class="dropdown-toggle btn btn-default" + data-toggle="dropdown" + aria-expanded="false" + > + <span> + {{ selectedVersionName }} + </span> + <Icon + :size="12" + name="angle-down" + /> + </a> + <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> + <div class="dropdown-content"> + <ul> + <li + v-for="version in targetVersions" + :key="version.id" + > + <a + :class="{ 'is-active': isActive(version) }" + :href="href(version)" + > + <div> + <strong> + {{ versionName(version) }} + <template v-if="isBase(version)"> + (base) + </template> + </strong> + </div> + <div> + <small class="commit-sha"> + {{ version.truncatedCommitSha }} + </small> + </div> + <div> + <small> + <template v-if="showCommitCount"> + {{ commitsText(version) }} + </template> + <time-ago + v-if="version.createdAt" + :time="version.createdAt" + class="js-timeago js-timeago-render" + /> + </small> + </div> + </a> + </li> + </ul> + </div> + </div> + </span> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue new file mode 100644 index 00000000000..48ba967285f --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -0,0 +1,62 @@ +<script> +import { mapGetters, mapState } from 'vuex'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import { diffModes } from '~/ide/constants'; +import InlineDiffView from './inline_diff_view.vue'; +import ParallelDiffView from './parallel_diff_view.vue'; + +export default { + components: { + InlineDiffView, + ParallelDiffView, + DiffViewer, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState({ + projectPath: state => state.diffs.projectPath, + endpoint: state => state.diffs.endpoint, + }), + ...mapGetters(['isInlineView', 'isParallelView']), + diffMode() { + const diffModeKey = Object.keys(diffModes).find(key => this.diffFile[`${key}File`]); + return diffModes[diffModeKey] || diffModes.replaced; + }, + isTextFile() { + return this.diffFile.text; + }, + }, +}; +</script> + +<template> + <div class="diff-content"> + <div class="diff-viewer"> + <template v-if="isTextFile"> + <inline-diff-view + v-if="isInlineView" + :diff-file="diffFile" + :diff-lines="diffFile.highlightedDiffLines || []" + /> + <parallel-diff-view + v-else-if="isParallelView" + :diff-file="diffFile" + :diff-lines="diffFile.parallelDiffLines || []" + /> + </template> + <diff-viewer + v-else + :diff-mode="diffMode" + :new-path="diffFile.newPath" + :new-sha="diffFile.diffRefs.headSha" + :old-path="diffFile.oldPath" + :old-sha="diffFile.diffRefs.baseSha" + :project-path="projectPath"/> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue new file mode 100644 index 00000000000..39d535036f6 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -0,0 +1,39 @@ +<script> +import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; + +export default { + components: { + noteableDiscussion, + }, + props: { + discussions: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <div + v-if="discussions.length" + > + <div + v-for="discussion in discussions" + :key="discussion.id" + class="discussion-notes diff-discussions" + > + <ul + :data-discussion-id="discussion.id" + class="notes" + > + <noteable-discussion + :discussion="discussion" + :render-header="false" + :render-diff-file="false" + :always-expanded="true" + /> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue new file mode 100644 index 00000000000..108eefdac5f --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -0,0 +1,191 @@ +<script> +import { mapActions } from 'vuex'; +import _ from 'underscore'; +import { __, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import DiffFileHeader from './diff_file_header.vue'; +import DiffContent from './diff_content.vue'; + +export default { + components: { + DiffFileHeader, + DiffContent, + LoadingIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + isActive: false, + isLoadingCollapsedDiff: false, + forkMessageVisible: false, + }; + }, + computed: { + isDiscussionsExpanded() { + return true; // TODO: @fatihacet - Fix this. + }, + isCollapsed() { + return this.file.collapsed || false; + }, + viewBlobLink() { + return sprintf( + __('You can %{linkStart}view the blob%{linkEnd} instead.'), + { + linkStart: `<a href="${_.escape(this.file.viewPath)}">`, + linkEnd: '</a>', + }, + false, + ); + }, + }, + mounted() { + document.addEventListener('scroll', this.handleScroll); + }, + beforeDestroy() { + document.removeEventListener('scroll', this.handleScroll); + }, + methods: { + ...mapActions(['loadCollapsedDiff']), + handleToggle() { + const { collapsed, highlightedDiffLines, parallelDiffLines } = this.file; + + if (collapsed && !highlightedDiffLines && !parallelDiffLines.length) { + this.handleLoadCollapsedDiff(); + } else { + this.file.collapsed = !this.file.collapsed; + } + }, + handleScroll() { + if (!this.updating) { + requestAnimationFrame(this.scrollUpdate.bind(this)); + this.updating = true; + } + }, + scrollUpdate() { + const header = document.querySelector('.js-diff-files-changed'); + if (!header) { + this.updating = false; + return; + } + + const { top, bottom } = this.$el.getBoundingClientRect(); + const { top: topOfFixedHeader, bottom: bottomOfFixedHeader } = header.getBoundingClientRect(); + + const headerOverlapsContent = top < topOfFixedHeader && bottom > bottomOfFixedHeader; + const fullyAboveHeader = bottom < bottomOfFixedHeader; + const fullyBelowHeader = top > topOfFixedHeader; + + if (headerOverlapsContent && !this.isActive) { + this.$emit('setActive'); + this.isActive = true; + } else if (this.isActive && (fullyAboveHeader || fullyBelowHeader)) { + this.$emit('unsetActive'); + this.isActive = false; + } + + this.updating = false; + }, + handleLoadCollapsedDiff() { + this.isLoadingCollapsedDiff = true; + + this.loadCollapsedDiff(this.file) + .then(() => { + this.isLoadingCollapsedDiff = false; + this.file.collapsed = false; + }) + .catch(() => { + this.isLoadingCollapsedDiff = false; + createFlash(__('Something went wrong on our end. Please try again!')); + }); + }, + showForkMessage() { + this.forkMessageVisible = true; + }, + hideForkMessage() { + this.forkMessageVisible = false; + }, + }, +}; +</script> + +<template> + <div + :id="file.fileHash" + class="diff-file file-holder" + > + <diff-file-header + :current-user="currentUser" + :diff-file="file" + :collapsible="true" + :expanded="!isCollapsed" + :discussions-expanded="isDiscussionsExpanded" + :add-merge-request-buttons="true" + class="js-file-title file-title" + @toggleFile="handleToggle" + @showForkMessage="showForkMessage" + /> + + <div + v-if="forkMessageVisible" + class="js-file-fork-suggestion-section file-fork-suggestion"> + <span class="file-fork-suggestion-note"> + You're not allowed to <span class="js-file-fork-suggestion-section-action">edit</span> + files in this project directly. Please fork this project, + make your changes there, and submit a merge request. + </span> + <a + :href="file.forkPath" + class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" + > + Fork + </a> + <button + class="js-cancel-fork-suggestion-button btn btn-grouped" + type="button" + @click="hideForkMessage" + > + Cancel + </button> + </div> + + <diff-content + v-show="!isCollapsed" + :class="{ hidden: isCollapsed || file.tooLarge }" + :diff-file="file" + /> + <loading-icon + v-if="isLoadingCollapsedDiff" + class="diff-content loading" + /> + <div + v-show="isCollapsed && !isLoadingCollapsedDiff && !file.tooLarge" + class="nothing-here-block diff-collapsed" + > + {{ __('This diff is collapsed.') }} + <a + class="click-to-expand js-click-to-expand" + href="#" + @click.prevent="handleToggle" + > + {{ __('Click to expand it.') }} + </a> + </div> + <div + v-if="file.tooLarge" + class="nothing-here-block diff-collapsed js-too-large-diff" + > + {{ __('This source diff could not be displayed because it is too large.') }} + <span v-html="viewBlobLink"></span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue new file mode 100644 index 00000000000..fba1d1af7cd --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -0,0 +1,258 @@ +<script> +import _ from 'underscore'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import Tooltip from '~/vue_shared/directives/tooltip'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { __, s__, sprintf } from '~/locale'; +import EditButton from './edit_button.vue'; + +export default { + components: { + ClipboardButton, + EditButton, + Icon, + }, + directives: { + Tooltip, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + collapsible: { + type: Boolean, + required: false, + default: false, + }, + addMergeRequestButtons: { + type: Boolean, + required: false, + default: false, + }, + expanded: { + type: Boolean, + required: false, + default: true, + }, + discussionsExpanded: { + type: Boolean, + required: false, + default: true, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + blobForkSuggestion: null, + }; + }, + computed: { + icon() { + if (this.diffFile.submodule) { + return 'archive'; + } + + return this.diffFile.blob.icon; + }, + titleLink() { + if (this.diffFile.submodule) { + return this.diffFile.submoduleTreeUrl || this.diffFile.submoduleLink; + } + + return `#${this.diffFile.fileHash}`; + }, + filePath() { + if (this.diffFile.submodule) { + return `${this.diffFile.filePath} @ ${truncateSha(this.diffFile.blob.id)}`; + } + + if (this.diffFile.deletedFile) { + return sprintf(__('%{filePath} deleted'), { filePath: this.diffFile.filePath }, false); + } + + return this.diffFile.filePath; + }, + titleTag() { + return this.diffFile.fileHash ? 'a' : 'span'; + }, + isUsingLfs() { + return this.diffFile.storedExternally && this.diffFile.externalStorage === 'lfs'; + }, + collapseIcon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + isDiscussionsExpanded() { + return this.discussionsExpanded && this.expanded; + }, + viewFileButtonText() { + const truncatedContentSha = _.escape(truncateSha(this.diffFile.contentSha)); + return sprintf( + s__('MergeRequests|View file @ %{commitId}'), + { + commitId: `<span class="commit-sha">${truncatedContentSha}</span>`, + }, + false, + ); + }, + viewReplacedFileButtonText() { + const truncatedBaseSha = _.escape(truncateSha(this.diffFile.diffRefs.baseSha)); + return sprintf( + s__('MergeRequests|View replaced file @ %{commitId}'), + { + commitId: `<span class="commit-sha">${truncatedBaseSha}</span>`, + }, + false, + ); + }, + }, + methods: { + handleToggle(e, checkTarget) { + if ( + !checkTarget || + e.target === this.$refs.header || + (e.target.classList && e.target.classList.contains('diff-toggle-caret')) + ) { + this.$emit('toggleFile'); + } + }, + showForkMessage() { + this.$emit('showForkMessage'); + }, + }, +}; +</script> + +<template> + <div + ref="header" + class="js-file-title file-title file-title-flex-parent" + @click="handleToggle($event, true)" + > + <div class="file-header-content"> + <icon + v-if="collapsible" + :name="collapseIcon" + :size="16" + aria-hidden="true" + class="diff-toggle-caret" + @click.stop="handleToggle" + /> + <a + ref="titleWrapper" + :href="titleLink" + > + <i + :class="`fa-${icon}`" + class="fa fa-fw" + aria-hidden="true" + ></i> + <span v-if="diffFile.renamedFile"> + <strong + v-tooltip + :title="diffFile.oldPath" + class="file-title-name" + data-container="body" + > + {{ diffFile.oldPath }} + </strong> + → + <strong + v-tooltip + :title="diffFile.newPath" + class="file-title-name" + data-container="body" + > + {{ diffFile.newPath }} + </strong> + </span> + + <strong + v-tooltip + v-else + :title="filePath" + class="file-title-name" + data-container="body" + > + {{ filePath }} + </strong> + </a> + + <clipboard-button + :title="__('Copy file path to clipboard')" + :text="diffFile.filePath" + css-class="btn-default btn-transparent btn-clipboard" + /> + + <small + v-if="diffFile.modeChanged" + ref="fileMode" + > + {{ diffFile.aMode }} → {{ diffFile.bMode }} + </small> + + <span + v-if="isUsingLfs" + class="label label-lfs append-right-5" + > + {{ __('LFS') }} + </span> + </div> + + <div + v-if="!diffFile.submodule && addMergeRequestButtons" + class="file-actions d-none d-sm-block" + > + <template + v-if="diffFile.blob && diffFile.blob.readableText" + > + <button + :class="{ active: isDiscussionsExpanded }" + :title="s__('MergeRequests|Toggle comments for this file')" + class="btn js-toggle-diff-comments" + type="button" + > + <icon name="comment" /> + </button> + + <edit-button + v-if="!diffFile.deletedFile" + :current-user="currentUser" + :edit-path="diffFile.editPath" + :can-modify-blob="diffFile.canModifyBlob" + @showForkMessage="showForkMessage" + /> + </template> + + <a + v-if="diffFile.replacedViewPath" + :href="diffFile.replacedViewPath" + class="btn view-file js-view-file" + v-html="viewReplacedFileButtonText" + > + </a> + <a + :href="diffFile.viewPath" + class="btn view-file js-view-file" + v-html="viewFileButtonText" + > + </a> + + <a + v-tooltip + v-if="diffFile.externalUrl" + :href="diffFile.externalUrl" + :title="`View on ${diffFile.formattedExternalUrl}`" + target="_blank" + rel="noopener noreferrer" + class="btn btn-file-option" + > + <icon name="external-link" /> + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue new file mode 100644 index 00000000000..7e50a0aed84 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -0,0 +1,105 @@ +<script> +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { pluralize, truncate } from '~/lib/utils/text_utility'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants'; + +export default { + directives: { + tooltip, + }, + components: { + Icon, + UserAvatarImage, + }, + props: { + discussions: { + type: Array, + required: true, + }, + }, + computed: { + discussionsExpanded() { + return this.discussions.every(discussion => discussion.expanded); + }, + allDiscussions() { + return this.discussions.reduce((acc, note) => acc.concat(note.notes), []); + }, + notesInGutter() { + return this.allDiscussions.slice(0, COUNT_OF_AVATARS_IN_GUTTER).map(n => ({ + note: n.note, + author: n.author, + })); + }, + moreCount() { + return this.allDiscussions.length - this.notesInGutter.length; + }, + moreText() { + if (this.moreCount === 0) { + return ''; + } + + return pluralize(`${this.moreCount} more comment`, this.moreCount); + }, + }, + methods: { + ...mapActions(['toggleDiscussion']), + getTooltipText(noteData) { + let { note } = noteData; + + if (note.length > LENGTH_OF_AVATAR_TOOLTIP) { + note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP); + } + + return `${noteData.author.name}: ${note}`; + }, + toggleDiscussions() { + this.discussions.forEach(discussion => { + this.toggleDiscussion({ + discussionId: discussion.id, + }); + }); + }, + }, +}; +</script> + +<template> + <div class="diff-comment-avatar-holders"> + <button + v-if="discussionsExpanded" + type="button" + aria-label="Show comments" + class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button" + @click="toggleDiscussions" + > + <icon + :size="12" + name="collapse" + /> + </button> + <template v-else> + <user-avatar-image + v-for="note in notesInGutter" + :key="note.id" + :img-src="note.author.avatar_url" + :tooltip-text="getTooltipText(note)" + :size="19" + class="diff-comment-avatar js-diff-comment-avatar" + @click.native="toggleDiscussions" + /> + <span + v-tooltip + v-if="moreText" + :title="moreText" + class="diff-comments-more-count has-tooltip js-diff-comment-avatar js-diff-comment-plus" + data-container="body" + data-placement="top" + role="button" + @click="toggleDiscussions" + >+{{ moreCount }}</span> + </template> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue new file mode 100644 index 00000000000..a74ea4bfaaf --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -0,0 +1,202 @@ +<script> +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { mapState, mapGetters, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import { LINE_POSITION_RIGHT, UNFOLD_COUNT } from '../constants'; +import * as utils from '../store/utils'; + +export default { + components: { + DiffGutterAvatars, + Icon, + }, + props: { + fileHash: { + type: String, + required: true, + }, + contextLinesPath: { + type: String, + required: true, + }, + lineType: { + type: String, + required: false, + default: '', + }, + lineNumber: { + type: Number, + required: false, + default: 0, + }, + lineCode: { + type: String, + required: false, + default: '', + }, + linePosition: { + type: String, + required: false, + default: '', + }, + metaData: { + type: Object, + required: false, + default: () => ({}), + }, + showCommentButton: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + isMatchLine: { + type: Boolean, + required: false, + default: false, + }, + isMetaLine: { + type: Boolean, + required: false, + default: false, + }, + isContextLine: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState({ + diffViewType: state => state.diffs.diffViewType, + diffFiles: state => state.diffs.diffFiles, + }), + ...mapGetters(['isLoggedIn', 'discussionsByLineCode']), + lineHref() { + return this.lineCode ? `#${this.lineCode}` : '#'; + }, + shouldShowCommentButton() { + return ( + this.isLoggedIn && + this.showCommentButton && + !this.isMatchLine && + !this.isContextLine && + !this.hasDiscussions && + !this.isMetaLine + ); + }, + discussions() { + return this.discussionsByLineCode[this.lineCode] || []; + }, + hasDiscussions() { + return this.discussions.length > 0; + }, + shouldShowAvatarsOnGutter() { + let render = this.hasDiscussions && this.showCommentButton; + + if (!this.lineType && this.linePosition === LINE_POSITION_RIGHT) { + render = false; + } + + return render; + }, + }, + methods: { + ...mapActions(['loadMoreLines', 'showCommentForm']), + handleCommentButton() { + this.showCommentForm({ lineCode: this.lineCode }); + }, + handleLoadMoreLines() { + if (this.isRequesting) { + return; + } + + this.isRequesting = true; + const endpoint = this.contextLinesPath; + const oldLineNumber = this.metaData.oldPos || 0; + const newLineNumber = this.metaData.newPos || 0; + const offset = newLineNumber - oldLineNumber; + const bottom = this.isBottom; + const { fileHash } = this; + const view = this.diffViewType; + let unfold = true; + let lineNumber = newLineNumber - 1; + let since = lineNumber - UNFOLD_COUNT; + let to = lineNumber; + + if (bottom) { + lineNumber = newLineNumber + 1; + since = lineNumber; + to = lineNumber + UNFOLD_COUNT; + } else { + const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); + const indexForInline = utils.findIndexInInlineLines(diffFile.highlightedDiffLines, { + oldLineNumber, + newLineNumber, + }); + const prevLine = diffFile.highlightedDiffLines[indexForInline - 2]; + const prevLineNumber = (prevLine && prevLine.newLine) || 0; + + if (since <= prevLineNumber + 1) { + since = prevLineNumber + 1; + unfold = false; + } + } + + const params = { since, to, bottom, offset, unfold, view }; + const lineNumbers = { oldLineNumber, newLineNumber }; + this.loadMoreLines({ endpoint, params, lineNumbers, fileHash }) + .then(() => { + this.isRequesting = false; + }) + .catch(() => { + createFlash(s__('Diffs|Something went wrong while fetching diff lines.')); + this.isRequesting = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <span + v-if="isMatchLine" + class="context-cell" + role="button" + @click="handleLoadMoreLines" + >...</span> + <template + v-else + > + <button + v-show="shouldShowCommentButton" + type="button" + class="add-diff-note js-add-diff-note-button" + title="Add a comment to this line" + @click="handleCommentButton" + > + <icon + :size="12" + name="comment" + /> + </button> + <a + v-if="lineNumber" + :data-linenumber="lineNumber" + :href="lineHref" + > + </a> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="discussions" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue new file mode 100644 index 00000000000..6943b462e86 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -0,0 +1,114 @@ +<script> +import $ from 'jquery'; +import { mapState, mapGetters, mapActions } from 'vuex'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import noteForm from '../../notes/components/note_form.vue'; +import { getNoteFormData } from '../store/utils'; +import Autosave from '../../autosave'; +import { DIFF_NOTE_TYPE, NOTE_TYPE } from '../constants'; + +export default { + components: { + noteForm, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + line: { + type: Object, + required: true, + }, + position: { + type: String, + required: false, + default: '', + }, + noteTargetLine: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState({ + noteableData: state => state.notes.noteableData, + diffViewType: state => state.diffs.diffViewType, + }), + ...mapGetters(['isLoggedIn', 'noteableType', 'getNoteableData', 'getNotesDataByProp']), + }, + mounted() { + if (this.isLoggedIn) { + const noteableData = this.getNoteableData; + const keys = [ + NOTE_TYPE, + this.noteableType, + noteableData.id, + noteableData.diff_head_sha, + DIFF_NOTE_TYPE, + noteableData.source_project_id, + this.line.lineCode, + ]; + + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), keys); + } + }, + methods: { + ...mapActions(['cancelCommentForm', 'saveNote', 'fetchDiscussions']), + handleCancelCommentForm() { + this.autosave.reset(); + this.cancelCommentForm({ + lineCode: this.line.lineCode, + }); + }, + handleSaveNote(note) { + const postData = getNoteFormData({ + note, + noteableData: this.noteableData, + noteableType: this.noteableType, + noteTargetLine: this.noteTargetLine, + diffViewType: this.diffViewType, + diffFile: this.diffFile, + linePosition: this.position, + }); + + this.saveNote(postData) + .then(() => { + const endpoint = this.getNotesDataByProp('discussionsPath'); + + this.fetchDiscussions(endpoint) + .then(() => { + this.handleCancelCommentForm(); + }) + .catch(() => { + createFlash(s__('MergeRequests|Updating discussions failed')); + }); + }) + .catch(() => { + createFlash(s__('MergeRequests|Saving the comment failed')); + }); + }, + }, +}; +</script> + +<template> + <div + class="content discussion-form discussion-form-container discussion-notes" + > + <note-form + ref="noteForm" + :is-editing="true" + :line-code="line.lineCode" + save-button-title="Comment" + class="diff-comment-form" + @cancelForm="handleCancelCommentForm" + @handleFormUpdate="handleSaveNote" + /> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue new file mode 100644 index 00000000000..68fe6787f9b --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -0,0 +1,131 @@ +<script> +import { mapGetters } from 'vuex'; +import DiffLineGutterContent from './diff_line_gutter_content.vue'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + EMPTY_CELL_TYPE, + OLD_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + LINE_UNFOLD_CLASS_NAME, +} from '../constants'; + +export default { + components: { + DiffLineGutterContent, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + showCommentButton: { + type: Boolean, + required: false, + default: false, + }, + linePosition: { + type: String, + required: false, + default: '', + }, + lineType: { + type: String, + required: false, + default: '', + }, + isContentLine: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + isHover: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters(['isLoggedIn', 'isInlineView']), + normalizedLine() { + if (this.isInlineView) { + return this.line; + } + + return this.lineType === OLD_LINE_TYPE ? this.line.left : this.line.right; + }, + isMatchLine() { + return this.normalizedLine.type === MATCH_LINE_TYPE; + }, + isContextLine() { + return this.normalizedLine.type === CONTEXT_LINE_TYPE; + }, + isMetaLine() { + return ( + this.normalizedLine.type === OLD_NO_NEW_LINE_TYPE || + this.normalizedLine.type === NEW_NO_NEW_LINE_TYPE || + this.normalizedLine.type === EMPTY_CELL_TYPE + ); + }, + classNameMap() { + const { type } = this.normalizedLine; + + return { + [type]: type, + [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine, + [LINE_HOVER_CLASS_NAME]: + this.isLoggedIn && + this.isHover && + !this.isMatchLine && + !this.isContextLine && + !this.isMetaLine, + }; + }, + lineNumber() { + const { lineType, normalizedLine } = this; + + return lineType === OLD_LINE_TYPE ? normalizedLine.oldLine : normalizedLine.newLine; + }, + }, +}; +</script> + +<template> + <td + v-if="isContentLine" + :class="lineType" + class="line_content" + v-html="normalizedLine.richText" + > + </td> + <td + v-else + :class="classNameMap" + > + <diff-line-gutter-content + :file-hash="diffFile.fileHash" + :line-type="normalizedLine.type" + :line-code="normalizedLine.lineCode" + :line-position="linePosition" + :line-number="lineNumber" + :meta-data="normalizedLine.metaData" + :show-comment-button="showCommentButton" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="isBottom" + :is-match-line="isMatchLine" + :is-context-line="isContentLine" + :is-meta-line="isMetaLine" + /> + </td> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_table_row.vue b/app/assets/javascripts/diffs/components/diff_table_row.vue new file mode 100644 index 00000000000..8716fdaf44d --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_table_row.vue @@ -0,0 +1,191 @@ +<script> +import $ from 'jquery'; +import { mapGetters } from 'vuex'; +import DiffTableCell from './diff_table_cell.vue'; +import { + NEW_LINE_TYPE, + OLD_LINE_TYPE, + CONTEXT_LINE_TYPE, + CONTEXT_LINE_CLASS_NAME, + OLD_NO_NEW_LINE_TYPE, + PARALLEL_DIFF_VIEW_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_POSITION_LEFT, + LINE_POSITION_RIGHT, +} from '../constants'; + +export default { + components: { + DiffTableCell, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + line: { + type: Object, + required: true, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isHover: false, + isLeftHover: false, + isRightHover: false, + }; + }, + computed: { + ...mapGetters(['isInlineView', 'isParallelView']), + isContextLine() { + return this.line.left + ? this.line.left.type === CONTEXT_LINE_TYPE + : this.line.type === CONTEXT_LINE_TYPE; + }, + classNameMap() { + return { + [this.line.type]: this.line.type, + [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, + [PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView, + }; + }, + inlineRowId() { + const { lineCode, oldLine, newLine } = this.line; + + return lineCode || `${this.diffFile.fileHash}_${oldLine}_${newLine}`; + }, + parallelViewLeftLineType() { + if (this.line.right.type === NEW_NO_NEW_LINE_TYPE) { + return OLD_NO_NEW_LINE_TYPE; + } + + return this.line.left.type; + }, + }, + created() { + this.newLineType = NEW_LINE_TYPE; + this.oldLineType = OLD_LINE_TYPE; + this.linePositionLeft = LINE_POSITION_LEFT; + this.linePositionRight = LINE_POSITION_RIGHT; + }, + methods: { + handleMouseMove(e) { + const isHover = e.type === 'mouseover'; + + if (this.isInlineView) { + this.isHover = isHover; + } else { + const hoveringCell = e.target.closest('td'); + const allCellsInHoveringRow = Array.from(e.currentTarget.children); + const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell); + + if (hoverIndex >= 2) { + this.isRightHover = isHover; + } else { + this.isLeftHover = isHover; + } + } + }, + // Prevent text selecting on both sides of parallel diff view + // Backport of the same code from legacy diff notes. + handleParallelLineMouseDown(e) { + const line = $(e.currentTarget); + const table = line.closest('table'); + + table.removeClass('left-side-selected right-side-selected'); + const [lineClass] = ['left-side', 'right-side'].filter(name => line.hasClass(name)); + + if (lineClass) { + table.addClass(`${lineClass}-selected`); + } + }, + }, +}; +</script> + +<template> + <tr + v-if="isInlineView" + :id="inlineRowId" + :class="classNameMap" + class="line_holder" + @mouseover="handleMouseMove" + @mouseout="handleMouseMove" + > + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="oldLineType" + :is-bottom="isBottom" + :is-hover="isHover" + :show-comment-button="true" + class="diff-line-num old_line" + /> + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="newLineType" + :is-bottom="isBottom" + :is-hover="isHover" + class="diff-line-num new_line" + /> + <diff-table-cell + :class="line.type" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + /> + </tr> + + <tr + v-else + :class="classNameMap" + class="line_holder" + @mouseover="handleMouseMove" + @mouseout="handleMouseMove" + > + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="oldLineType" + :line-position="linePositionLeft" + :is-bottom="isBottom" + :is-hover="isLeftHover" + :show-comment-button="true" + class="diff-line-num old_line" + /> + <diff-table-cell + :id="line.left.lineCode" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + :line-type="parallelViewLeftLineType" + class="line_content parallel left-side" + @mousedown.native="handleParallelLineMouseDown" + /> + <diff-table-cell + :diff-file="diffFile" + :line="line" + :line-type="newLineType" + :line-position="linePositionRight" + :is-bottom="isBottom" + :is-hover="isRightHover" + :show-comment-button="true" + class="diff-line-num new_line" + /> + <diff-table-cell + :id="line.right.lineCode" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + :line-type="line.right.type" + class="line_content parallel right-side" + @mousedown.native="handleParallelLineMouseDown" + /> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue new file mode 100644 index 00000000000..ebf90631d76 --- /dev/null +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -0,0 +1,42 @@ +<script> +export default { + props: { + editPath: { + type: String, + required: true, + }, + currentUser: { + type: Object, + required: true, + }, + canModifyBlob: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + handleEditClick(evt) { + if (!this.currentUser || this.canModifyBlob) { + // if we can Edit, do default Edit button behavior + return; + } + + if (this.currentUser.canFork && this.currentUser.canCreateMergeRequest) { + evt.preventDefault(); + this.$emit('showForkMessage'); + } + }, + }, +}; +</script> + +<template> + <a + :href="editPath" + class="btn btn-default js-edit-blob" + @click="handleEditClick" + > + Edit + </a> +</template> diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue new file mode 100644 index 00000000000..017dcfcc357 --- /dev/null +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -0,0 +1,51 @@ +<script> +export default { + props: { + total: { + type: String, + required: true, + }, + visible: { + type: Number, + required: true, + }, + plainDiffPath: { + type: String, + required: true, + }, + emailPatchPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="alert alert-warning"> + <h4> + {{ __('Too many changes to show.') }} + <div class="pull-right"> + <a + :href="plainDiffPath" + class="btn btn-sm" + > + {{ __('Plain diff') }} + </a> + <a + :href="emailPatchPath" + class="btn btn-sm" + > + {{ __('Email patch') }} + </a> + </div> + </h4> + <p> + To preserve performance only + <strong> + {{ visible }} of {{ total }} + </strong> + files are displayed. + </p> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue new file mode 100644 index 00000000000..0e935f1d68e --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -0,0 +1,82 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import diffDiscussions from './diff_discussions.vue'; +import diffLineNoteForm from './diff_line_note_form.vue'; + +export default { + components: { + diffDiscussions, + diffLineNoteForm, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + lineIndex: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState({ + diffLineCommentForms: state => state.diffs.diffLineCommentForms, + }), + ...mapGetters(['discussionsByLineCode']), + isDiscussionExpanded() { + if (!this.discussions.length) { + return false; + } + + return this.discussions.every(discussion => discussion.expanded); + }, + hasCommentForm() { + return this.diffLineCommentForms[this.line.lineCode]; + }, + discussions() { + return this.discussionsByLineCode[this.line.lineCode] || []; + }, + shouldRender() { + return this.isDiscussionExpanded || this.hasCommentForm; + }, + className() { + return this.discussions.length ? '' : 'js-temp-notes-holder'; + }, + }, +}; +</script> + +<template> + <tr + v-if="shouldRender" + :class="className" + class="notes_holder" + > + <td + class="notes_line" + colspan="2" + ></td> + <td class="notes_content"> + <div class="content"> + <diff-discussions + :discussions="discussions" + /> + <diff-line-note-form + v-if="diffLineCommentForms[line.lineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line" + :note-target-line="diffLines[lineIndex]" + /> + </div> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue new file mode 100644 index 00000000000..e72f85df77a --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -0,0 +1,38 @@ +<script> +import diffContentMixin from '../mixins/diff_content'; +import inlineDiffCommentRow from './inline_diff_comment_row.vue'; + +export default { + components: { + inlineDiffCommentRow, + }, + mixins: [diffContentMixin], +}; +</script> + +<template> + <table + :class="userColorScheme" + :data-commit-id="commitId" + class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"> + <tbody> + <template + v-for="(line, index) in normalizedDiffLines" + > + <diff-table-row + :diff-file="diffFile" + :line="line" + :is-bottom="index + 1 === diffLinesLength" + :key="line.lineCode" + /> + <inline-diff-comment-row + :diff-file="diffFile" + :diff-lines="normalizedDiffLines" + :line="line" + :line-index="index" + :key="index" + /> + </template> + </tbody> + </table> +</template> diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue new file mode 100644 index 00000000000..d817157fbcd --- /dev/null +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -0,0 +1,49 @@ +<script> +import { mapState } from 'vuex'; +import emptyImage from '~/../../views/shared/icons/_mr_widget_empty_state.svg'; + +export default { + data() { + return { + emptyImage, + }; + }, + computed: { + ...mapState({ + sourceBranch: state => state.notes.noteableData.source_branch, + targetBranch: state => state.notes.noteableData.target_branch, + newBlobPath: state => state.notes.noteableData.new_blob_path, + }), + }, +}; +</script> + +<template> + <div + class="row empty-state nothing-here-block" + > + <div class="col-xs-12"> + <div class="svg-content"> + <span + v-html="emptyImage" + ></span> + </div> + </div> + <div class="col-xs-12"> + <div class="text-content text-center"> + No changes between + <span class="ref-name">{{ sourceBranch }}</span> + and + <span class="ref-name">{{ targetBranch }}</span> + <div class="text-center"> + <a + :href="newBlobPath" + class="btn btn-save" + > + {{ __('Create commit') }} + </a> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue new file mode 100644 index 00000000000..5f33ec7a3c2 --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -0,0 +1,129 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import diffDiscussions from './diff_discussions.vue'; +import diffLineNoteForm from './diff_line_note_form.vue'; + +export default { + components: { + diffDiscussions, + diffLineNoteForm, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + lineIndex: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState({ + diffLineCommentForms: state => state.diffs.diffLineCommentForms, + }), + ...mapGetters(['discussionsByLineCode']), + leftLineCode() { + return this.line.left.lineCode; + }, + rightLineCode() { + return this.line.right.lineCode; + }, + hasDiscussion() { + const discussions = this.discussionsByLineCode; + + return discussions[this.leftLineCode] || discussions[this.rightLineCode]; + }, + hasExpandedDiscussionOnLeft() { + const discussions = this.discussionsByLineCode[this.leftLineCode]; + + return discussions ? discussions.every(discussion => discussion.expanded) : false; + }, + hasExpandedDiscussionOnRight() { + const discussions = this.discussionsByLineCode[this.rightLineCode]; + + return discussions ? discussions.every(discussion => discussion.expanded) : false; + }, + hasAnyExpandedDiscussion() { + return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; + }, + shouldRenderDiscussionsRow() { + const hasDiscussion = this.hasDiscussion && this.hasAnyExpandedDiscussion; + const hasCommentFormOnLeft = this.diffLineCommentForms[this.leftLineCode]; + const hasCommentFormOnRight = this.diffLineCommentForms[this.rightLineCode]; + + return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight; + }, + shouldRenderDiscussionsOnLeft() { + return this.discussionsByLineCode[this.leftLineCode] && this.hasExpandedDiscussionOnLeft; + }, + shouldRenderDiscussionsOnRight() { + return ( + this.discussionsByLineCode[this.rightLineCode] && + this.hasExpandedDiscussionOnRight && + this.line.right.type + ); + }, + className() { + return this.hasDiscussion ? '' : 'js-temp-notes-holder'; + }, + }, +}; +</script> + +<template> + <tr + v-if="shouldRenderDiscussionsRow" + :class="className" + class="notes_holder" + > + <td class="notes_line old"></td> + <td class="notes_content parallel old"> + <div + v-if="shouldRenderDiscussionsOnLeft" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[leftLineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[leftLineCode] && + diffLineCommentForms[leftLineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.left" + :note-target-line="diffLines[lineIndex].left" + position="left" + /> + </td> + <td class="notes_line new"></td> + <td class="notes_content parallel new"> + <div + v-if="shouldRenderDiscussionsOnRight" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[rightLineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[rightLineCode] && + diffLineCommentForms[rightLineCode] && line.right.type" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.right" + :note-target-line="diffLines[lineIndex].right" + position="right" + /> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue new file mode 100644 index 00000000000..ed92b4ee249 --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -0,0 +1,55 @@ +<script> +import diffContentMixin from '../mixins/diff_content'; +import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; +import { EMPTY_CELL_TYPE } from '../constants'; + +export default { + components: { + parallelDiffCommentRow, + }, + mixins: [diffContentMixin], + computed: { + parallelDiffLines() { + return this.normalizedDiffLines.map(line => { + if (!line.left) { + Object.assign(line, { left: { type: EMPTY_CELL_TYPE } }); + } else if (!line.right) { + Object.assign(line, { right: { type: EMPTY_CELL_TYPE } }); + } + + return line; + }); + }, + }, +}; +</script> + +<template> + <div + :class="userColorScheme" + :data-commit-id="commitId" + class="code diff-wrap-lines js-syntax-highlight text-file" + > + <table> + <tbody> + <template + v-for="(line, index) in parallelDiffLines" + > + <diff-table-row + :diff-file="diffFile" + :line="line" + :is-bottom="index + 1 === diffLinesLength" + :key="index" + /> + <parallel-diff-comment-row + :key="line.left.lineCode || line.right.lineCode" + :line="line" + :diff-file="diffFile" + :diff-lines="parallelDiffLines" + :line-index="index" + /> + </template> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js new file mode 100644 index 00000000000..2fa8367f528 --- /dev/null +++ b/app/assets/javascripts/diffs/constants.js @@ -0,0 +1,27 @@ +export const INLINE_DIFF_VIEW_TYPE = 'inline'; +export const PARALLEL_DIFF_VIEW_TYPE = 'parallel'; +export const MATCH_LINE_TYPE = 'match'; +export const OLD_NO_NEW_LINE_TYPE = 'old-nonewline'; +export const NEW_NO_NEW_LINE_TYPE = 'new-nonewline'; +export const CONTEXT_LINE_TYPE = 'context'; +export const EMPTY_CELL_TYPE = 'empty-cell'; +export const COMMENT_FORM_TYPE = 'commentForm'; +export const DIFF_NOTE_TYPE = 'DiffNote'; +export const NOTE_TYPE = 'Note'; +export const NEW_LINE_TYPE = 'new'; +export const OLD_LINE_TYPE = 'old'; +export const TEXT_DIFF_POSITION_TYPE = 'text'; + +export const LINE_POSITION_LEFT = 'left'; +export const LINE_POSITION_RIGHT = 'right'; +export const LINE_SIDE_LEFT = 'left-side'; +export const LINE_SIDE_RIGHT = 'right-side'; + +export const DIFF_VIEW_COOKIE_NAME = 'diff_view'; +export const LINE_HOVER_CLASS_NAME = 'is-over'; +export const LINE_UNFOLD_CLASS_NAME = 'unfold js-unfold'; +export const CONTEXT_LINE_CLASS_NAME = 'diff-expanded'; + +export const UNFOLD_COUNT = 20; +export const COUNT_OF_AVATARS_IN_GUTTER = 3; +export const LENGTH_OF_AVATAR_TOOLTIP = 17; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js new file mode 100644 index 00000000000..aae89109c27 --- /dev/null +++ b/app/assets/javascripts/diffs/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import { mapState } from 'vuex'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import diffsApp from './components/app.vue'; + +export default function initDiffsApp(store) { + return new Vue({ + el: '#js-diffs-app', + name: 'MergeRequestDiffs', + components: { + diffsApp, + }, + store, + data() { + const { dataset } = document.querySelector(this.$options.el); + + return { + endpoint: dataset.endpoint, + projectPath: dataset.projectPath, + currentUser: convertObjectPropsToCamelCase(JSON.parse(dataset.currentUserData), { + deep: true, + }), + }; + }, + computed: { + ...mapState({ + activeTab: state => state.page.activeTab, + }), + }, + render(createElement) { + return createElement('diffs-app', { + props: { + endpoint: this.endpoint, + currentUser: this.currentUser, + projectPath: this.projectPath, + shouldShow: this.activeTab === 'diffs', + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/diffs/mixins/changed_files.js b/app/assets/javascripts/diffs/mixins/changed_files.js new file mode 100644 index 00000000000..da1339f0ffa --- /dev/null +++ b/app/assets/javascripts/diffs/mixins/changed_files.js @@ -0,0 +1,38 @@ +export default { + props: { + diffFiles: { + type: Array, + required: true, + }, + }, + methods: { + fileChangedIcon(diffFile) { + if (diffFile.deletedFile) { + return 'file-deletion'; + } else if (diffFile.newFile) { + return 'file-addition'; + } + return 'file-modified'; + }, + fileChangedClass(diffFile) { + if (diffFile.deletedFile) { + return 'cred'; + } else if (diffFile.newFile) { + return 'cgreen'; + } + + return ''; + }, + truncatedDiffPath(path) { + const maxLength = 60; + + if (path.length > maxLength) { + const start = path.length - maxLength; + const end = start + maxLength; + return `...${path.slice(start, end)}`; + } + + return path; + }, + }, +}; diff --git a/app/assets/javascripts/diffs/mixins/diff_content.js b/app/assets/javascripts/diffs/mixins/diff_content.js new file mode 100644 index 00000000000..ebb511d3a7e --- /dev/null +++ b/app/assets/javascripts/diffs/mixins/diff_content.js @@ -0,0 +1,57 @@ +import { mapGetters } from 'vuex'; +import diffDiscussions from '../components/diff_discussions.vue'; +import diffLineGutterContent from '../components/diff_line_gutter_content.vue'; +import diffLineNoteForm from '../components/diff_line_note_form.vue'; +import diffTableRow from '../components/diff_table_row.vue'; +import { trimFirstCharOfLineContent } from '../store/utils'; + +export default { + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + }, + components: { + diffDiscussions, + diffTableRow, + diffLineNoteForm, + diffLineGutterContent, + }, + computed: { + ...mapGetters(['commit']), + commitId() { + return this.commit && this.commit.id; + }, + userColorScheme() { + return window.gon.user_color_scheme; + }, + normalizedDiffLines() { + return this.diffLines.map(line => { + if (line.richText) { + return trimFirstCharOfLineContent(line); + } + + if (line.left) { + Object.assign(line, { left: trimFirstCharOfLineContent(line.left) }); + } + + if (line.right) { + Object.assign(line, { right: trimFirstCharOfLineContent(line.right) }); + } + + return line; + }); + }, + diffLinesLength() { + return this.normalizedDiffLines.length; + }, + fileHash() { + return this.diffFile.fileHash; + }, + }, +}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js new file mode 100644 index 00000000000..5e0fd5109bb --- /dev/null +++ b/app/assets/javascripts/diffs/store/actions.js @@ -0,0 +1,95 @@ +import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import Cookies from 'js-cookie'; +import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import * as types from './mutation_types'; +import { + PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_VIEW_TYPE, + DIFF_VIEW_COOKIE_NAME, +} from '../constants'; + +export const setBaseConfig = ({ commit }, options) => { + const { endpoint, projectPath } = options; + commit(types.SET_BASE_CONFIG, { endpoint, projectPath }); +}; + +export const fetchDiffFiles = ({ state, commit }) => { + commit(types.SET_LOADING, true); + + return axios + .get(state.endpoint) + .then(res => { + commit(types.SET_LOADING, false); + commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []); + commit(types.SET_DIFF_DATA, res.data); + return Vue.nextTick(); + }) + .then(handleLocationHash); +}; + +export const setInlineDiffViewType = ({ commit }) => { + commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE); + + Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE); + const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href); + historyPushState(url); +}; + +export const setParallelDiffViewType = ({ commit }) => { + commit(types.SET_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE); + + Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE); + const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href); + historyPushState(url); +}; + +export const showCommentForm = ({ commit }, params) => { + commit(types.ADD_COMMENT_FORM_LINE, params); +}; + +export const cancelCommentForm = ({ commit }, params) => { + commit(types.REMOVE_COMMENT_FORM_LINE, params); +}; + +export const loadMoreLines = ({ commit }, options) => { + const { endpoint, params, lineNumbers, fileHash } = options; + + params.from_merge_request = true; + + return axios.get(endpoint, { params }).then(res => { + const contextLines = res.data || []; + + commit(types.ADD_CONTEXT_LINES, { + lineNumbers, + contextLines, + params, + fileHash, + }); + }); +}; + +export const loadCollapsedDiff = ({ commit }, file) => + axios.get(file.loadCollapsedDiffUrl).then(res => { + commit(types.ADD_COLLAPSED_DIFFS, { + file, + data: res.data, + }); + }); + +export const expandAllFiles = ({ commit }) => { + commit(types.EXPAND_ALL_FILES); +}; + +export default { + setBaseConfig, + fetchDiffFiles, + setInlineDiffViewType, + setParallelDiffViewType, + showCommentForm, + cancelCommentForm, + loadMoreLines, + loadCollapsedDiff, + expandAllFiles, +}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js new file mode 100644 index 00000000000..66d0f47d102 --- /dev/null +++ b/app/assets/javascripts/diffs/store/getters.js @@ -0,0 +1,16 @@ +import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; + +export default { + isParallelView(state) { + return state.diffViewType === PARALLEL_DIFF_VIEW_TYPE; + }, + isInlineView(state) { + return state.diffViewType === INLINE_DIFF_VIEW_TYPE; + }, + areAllFilesCollapsed(state) { + return state.diffFiles.every(file => file.collapsed); + }, + commit(state) { + return state.commit; + }, +}; diff --git a/app/assets/javascripts/diffs/store/index.js b/app/assets/javascripts/diffs/store/index.js new file mode 100644 index 00000000000..e6aa8f5b12a --- /dev/null +++ b/app/assets/javascripts/diffs/store/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import diffsModule from './modules'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + modules: { + diffs: diffsModule, + }, +}); diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js new file mode 100644 index 00000000000..94caa131506 --- /dev/null +++ b/app/assets/javascripts/diffs/store/modules/index.js @@ -0,0 +1,26 @@ +import Cookies from 'js-cookie'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import actions from '../actions'; +import getters from '../getters'; +import mutations from '../mutations'; +import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; + +const viewTypeFromQueryString = getParameterValues('view')[0]; +const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); +const defaultViewType = INLINE_DIFF_VIEW_TYPE; + +export default { + state: { + isLoading: true, + endpoint: '', + basePath: '', + commit: null, + diffFiles: [], + mergeRequestDiffs: [], + diffLineCommentForms: {}, + diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, + }, + getters, + actions, + mutations, +}; diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js new file mode 100644 index 00000000000..63e9239dce4 --- /dev/null +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -0,0 +1,11 @@ +export const SET_BASE_CONFIG = 'SET_BASE_CONFIG'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_DIFF_DATA = 'SET_DIFF_DATA'; +export const SET_DIFF_FILES = 'SET_DIFF_FILES'; +export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; +export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; +export const ADD_COMMENT_FORM_LINE = 'ADD_COMMENT_FORM_LINE'; +export const REMOVE_COMMENT_FORM_LINE = 'REMOVE_COMMENT_FORM_LINE'; +export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; +export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS'; +export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js new file mode 100644 index 00000000000..339a33f8b71 --- /dev/null +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -0,0 +1,86 @@ +import Vue from 'vue'; +import _ from 'underscore'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { findDiffFile, addLineReferences, removeMatchLine, addContextLines } from './utils'; +import * as types from './mutation_types'; + +export default { + [types.SET_BASE_CONFIG](state, options) { + const { endpoint, projectPath } = options; + Object.assign(state, { endpoint, projectPath }); + }, + + [types.SET_LOADING](state, isLoading) { + Object.assign(state, { isLoading }); + }, + + [types.SET_DIFF_DATA](state, data) { + Object.assign(state, { + ...convertObjectPropsToCamelCase(data, { deep: true }), + }); + }, + + [types.SET_DIFF_FILES](state, diffFiles) { + Object.assign(state, { + diffFiles: convertObjectPropsToCamelCase(diffFiles, { deep: true }), + }); + }, + + [types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) { + Object.assign(state, { + mergeRequestDiffs: convertObjectPropsToCamelCase(mergeRequestDiffs, { deep: true }), + }); + }, + + [types.SET_DIFF_VIEW_TYPE](state, diffViewType) { + Object.assign(state, { diffViewType }); + }, + + [types.ADD_COMMENT_FORM_LINE](state, { lineCode }) { + Vue.set(state.diffLineCommentForms, lineCode, true); + }, + + [types.REMOVE_COMMENT_FORM_LINE](state, { lineCode }) { + Vue.delete(state.diffLineCommentForms, lineCode); + }, + + [types.ADD_CONTEXT_LINES](state, options) { + const { lineNumbers, contextLines, fileHash } = options; + const { bottom } = options.params; + const diffFile = findDiffFile(state.diffFiles, fileHash); + const { highlightedDiffLines, parallelDiffLines } = diffFile; + + removeMatchLine(diffFile, lineNumbers, bottom); + const lines = addLineReferences(contextLines, lineNumbers, bottom); + addContextLines({ + inlineLines: highlightedDiffLines, + parallelLines: parallelDiffLines, + contextLines: lines, + bottom, + lineNumbers, + }); + }, + + [types.ADD_COLLAPSED_DIFFS](state, { file, data }) { + const normalizedData = convertObjectPropsToCamelCase(data, { deep: true }); + const [newFileData] = normalizedData.diffFiles.filter(f => f.fileHash === file.fileHash); + + if (newFileData) { + const index = _.findIndex(state.diffFiles, f => f.fileHash === file.fileHash); + state.diffFiles.splice(index, 1, newFileData); + } + }, + + [types.EXPAND_ALL_FILES](state) { + const diffFiles = []; + + state.diffFiles.forEach(file => { + diffFiles.push({ + ...file, + collapsed: false, + }); + }); + + Object.assign(state, { diffFiles }); + }, +}; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js new file mode 100644 index 00000000000..da7ae16aaf1 --- /dev/null +++ b/app/assets/javascripts/diffs/store/utils.js @@ -0,0 +1,172 @@ +import _ from 'underscore'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { + LINE_POSITION_LEFT, + LINE_POSITION_RIGHT, + TEXT_DIFF_POSITION_TYPE, + DIFF_NOTE_TYPE, + NEW_LINE_TYPE, + OLD_LINE_TYPE, + MATCH_LINE_TYPE, +} from '../constants'; + +export function findDiffFile(files, hash) { + return files.filter(file => file.fileHash === hash)[0]; +} + +export const getReversePosition = linePosition => { + if (linePosition === LINE_POSITION_RIGHT) { + return LINE_POSITION_LEFT; + } + + return LINE_POSITION_RIGHT; +}; + +export function getNoteFormData(params) { + const { + note, + noteableType, + noteableData, + diffFile, + noteTargetLine, + diffViewType, + linePosition, + } = params; + + const position = JSON.stringify({ + base_sha: diffFile.diffRefs.baseSha, + start_sha: diffFile.diffRefs.startSha, + head_sha: diffFile.diffRefs.headSha, + old_path: diffFile.oldPath, + new_path: diffFile.newPath, + position_type: TEXT_DIFF_POSITION_TYPE, + old_line: noteTargetLine.oldLine, + new_line: noteTargetLine.newLine, + }); + + const postData = { + view: diffViewType, + line_type: linePosition === LINE_POSITION_RIGHT ? NEW_LINE_TYPE : OLD_LINE_TYPE, + merge_request_diff_head_sha: diffFile.diffRefs.headSha, + in_reply_to_discussion_id: '', + note_project_id: '', + target_type: noteableData.targetType, + target_id: noteableData.id, + note: { + note, + position, + noteable_type: noteableType, + noteable_id: noteableData.id, + commit_id: '', + type: DIFF_NOTE_TYPE, + line_code: noteTargetLine.lineCode, + }, + }; + + return { + endpoint: noteableData.create_note_path, + data: postData, + }; +} + +export const findIndexInInlineLines = (lines, lineNumbers) => { + const { oldLineNumber, newLineNumber } = lineNumbers; + + return _.findIndex( + lines, + line => line.oldLine === oldLineNumber && line.newLine === newLineNumber, + ); +}; + +export const findIndexInParallelLines = (lines, lineNumbers) => { + const { oldLineNumber, newLineNumber } = lineNumbers; + + return _.findIndex( + lines, + line => + line.left && + line.right && + line.left.oldLine === oldLineNumber && + line.right.newLine === newLineNumber, + ); +}; + +export function removeMatchLine(diffFile, lineNumbers, bottom) { + const indexForInline = findIndexInInlineLines(diffFile.highlightedDiffLines, lineNumbers); + const indexForParallel = findIndexInParallelLines(diffFile.parallelDiffLines, lineNumbers); + const factor = bottom ? 1 : -1; + + diffFile.highlightedDiffLines.splice(indexForInline + factor, 1); + diffFile.parallelDiffLines.splice(indexForParallel + factor, 1); +} + +export function addLineReferences(lines, lineNumbers, bottom) { + const { oldLineNumber, newLineNumber } = lineNumbers; + const lineCount = lines.length; + let matchLineIndex = -1; + + const linesWithNumbers = lines.map((l, index) => { + const line = convertObjectPropsToCamelCase(l); + + if (line.type === MATCH_LINE_TYPE) { + matchLineIndex = index; + } else { + Object.assign(line, { + oldLine: bottom ? oldLineNumber + index + 1 : oldLineNumber + index - lineCount, + newLine: bottom ? newLineNumber + index + 1 : newLineNumber + index - lineCount, + }); + } + + return line; + }); + + if (matchLineIndex > -1) { + const line = linesWithNumbers[matchLineIndex]; + const targetLine = bottom + ? linesWithNumbers[matchLineIndex - 1] + : linesWithNumbers[matchLineIndex + 1]; + + Object.assign(line, { + metaData: { + oldPos: targetLine.oldLine, + newPos: targetLine.newLine, + }, + }); + } + + return linesWithNumbers; +} + +export function addContextLines(options) { + const { inlineLines, parallelLines, contextLines, lineNumbers } = options; + const normalizedParallelLines = contextLines.map(line => ({ + left: line, + right: line, + })); + + if (options.bottom) { + inlineLines.push(...contextLines); + parallelLines.push(...normalizedParallelLines); + } else { + const inlineIndex = findIndexInInlineLines(inlineLines, lineNumbers); + const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers); + inlineLines.splice(inlineIndex, 0, ...contextLines); + parallelLines.splice(parallelIndex, 0, ...normalizedParallelLines); + } +} + +export function trimFirstCharOfLineContent(line) { + if (!line.richText) { + return line; + } + + const firstChar = line.richText.charAt(0); + + if (firstChar === ' ' || firstChar === '+' || firstChar === '-') { + Object.assign(line, { + richText: line.richText.substring(1), + }); + } + + return line; +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 72f21f13860..a5af37e80b6 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,12 +1,12 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ +/* eslint-disable consistent-return, no-new */ import $ from 'jquery'; -import Flash from './flash'; import GfmAutoComplete from './gfm_auto_complete'; import { convertPermissionToBoolean } from './lib/utils/common_utils'; import GlFieldErrors from './gl_field_errors'; import Shortcuts from './shortcuts'; import SearchAutocomplete from './search_autocomplete'; +import performanceBar from './performance_bar'; function initSearch() { // Only when search form is present @@ -72,9 +72,7 @@ function initGFMInput() { function initPerformanceBar() { if (document.querySelector('#js-peek')) { - import('./performance_bar') - .then(m => new m.default({ container: '#js-peek' })) // eslint-disable-line new-cap - .catch(() => Flash('Error loading performance bar module')); + performanceBar({ container: '#js-peek' }); } } diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 4164149dd06..17ea3bdb179 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -1,7 +1,6 @@ -/* global dateFormat */ - import $ from 'jquery'; import Pikaday from 'pikaday'; +import dateFormat from 'dateformat'; import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; @@ -55,7 +54,7 @@ class DueDateSelect { format: 'yyyy-mm-dd', parse: dateString => parsePikadayDate(dateString), toString: date => pikadayToString(date), - onSelect: (dateText) => { + onSelect: dateText => { $dueDateInput.val(calendar.toString(dateText)); if (this.$dropdown.hasClass('js-issue-boards-due-date')) { @@ -73,7 +72,7 @@ class DueDateSelect { } initRemoveDueDate() { - this.$block.on('click', '.js-remove-due-date', (e) => { + this.$block.on('click', '.js-remove-due-date', e => { const calendar = this.$datePicker.data('pikaday'); e.preventDefault(); @@ -124,7 +123,8 @@ class DueDateSelect { this.$loading.fadeOut(); }; - gl.issueBoards.BoardsStore.detail.issue.update(this.$dropdown.attr('data-issue-update')) + gl.issueBoards.BoardsStore.detail.issue + .update(this.$dropdown.attr('data-issue-update')) .then(fadeOutLoader) .catch(fadeOutLoader); } @@ -147,17 +147,18 @@ class DueDateSelect { $('.js-remove-due-date-holder').toggleClass('hidden', selectedDateValue.length); - return axios.put(this.issueUpdateURL, this.datePayload) - .then(() => { - const tooltipText = hasDueDate ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` : __('Due date'); - if (isDropdown) { - this.$dropdown.trigger('loaded.gl.dropdown'); - this.$dropdown.dropdown('toggle'); - } - this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); + return axios.put(this.issueUpdateURL, this.datePayload).then(() => { + const tooltipText = hasDueDate + ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` + : __('Due date'); + if (isDropdown) { + this.$dropdown.trigger('loaded.gl.dropdown'); + this.$dropdown.dropdown('toggle'); + } + this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); - return this.$loading.fadeOut(); - }); + return this.$loading.fadeOut(); + }); } } @@ -187,15 +188,19 @@ export default class DueDateSelectors { $datePicker.data('pikaday', calendar); }); - $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { + $('.js-clear-due-date,.js-clear-start-date').on('click', e => { e.preventDefault(); - const calendar = $(e.target).siblings('.datepicker').data('pikaday'); + const calendar = $(e.target) + .siblings('.datepicker') + .data('pikaday'); calendar.setDate(null); }); } // eslint-disable-next-line class-methods-use-this initIssuableSelect() { - const $loading = $('.js-issuable-update .due_date').find('.block-loading').hide(); + const $loading = $('.js-issuable-update .due_date') + .find('.block-loading') + .hide(); $('.js-due-date-select').each((i, dropdown) => { const $dropdown = $(dropdown); diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 6bd7c6b49cb..9aa224fa407 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -43,17 +43,17 @@ <div class="environments-container"> <loading-icon + v-if="isLoading" class="prepend-top-default" label="Loading environments" - v-if="isLoading" size="3" /> <slot name="emptyState"></slot> <div - class="table-holder" - v-if="!isLoading && environments.length > 0"> + v-if="!isLoading && environments.length > 0" + class="table-holder"> <environment-table :environments="environments" diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index 0b3fef9fcca..e3652fe739e 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -52,18 +52,18 @@ role="group"> <button v-tooltip + :title="title" + :aria-label="title" + :disabled="isLoading" type="button" class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container" data-container="body" data-toggle="dropdown" - :title="title" - :aria-label="title" - :disabled="isLoading" > <span> <icon - name="play" :size="12" + name="play" /> <i class="fa fa-caret-down" @@ -79,15 +79,15 @@ v-for="(action, i) in actions" :key="i"> <button + :class="{ disabled: isActionDisabled(action) }" + :disabled="isActionDisabled(action)" type="button" class="js-manual-action-link no-btn btn" @click="onClickAction(action.play_path)" - :class="{ disabled: isActionDisabled(action) }" - :disabled="isActionDisabled(action)" > <icon - name="play" :size="12" + name="play" /> <span> {{ action.name }} diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue index ea6f1168c68..68195225d50 100644 --- a/app/assets/javascripts/environments/components/environment_external_url.vue +++ b/app/assets/javascripts/environments/components/environment_external_url.vue @@ -29,17 +29,17 @@ <template> <a v-tooltip + :title="title" + :aria-label="title" + :href="externalUrl" class="btn external-url" data-container="body" target="_blank" rel="noopener noreferrer nofollow" - :title="title" - :aria-label="title" - :href="externalUrl" > <icon - name="external-link" :size="12" + name="external-link" /> </a> </template> diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 23aaab2c441..5ecdccf63ad 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -292,7 +292,7 @@ if (this.model && this.model.last_deployment && this.model.last_deployment.deployable) { - const deployable = this.model.last_deployment.deployable; + const { deployable } = this.model.last_deployment; return `${deployable.name} #${deployable.id}`; } return ''; @@ -427,11 +427,11 @@ </script> <template> <div - class="gl-responsive-table-row" :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, }" + class="gl-responsive-table-row" role="row"> <div class="table-section section-10" @@ -446,19 +446,19 @@ </div> <a v-if="!model.isFolder" - class="environment-name flex-truncate-parent table-mobile-content" - :href="environmentPath"> + :href="environmentPath" + class="environment-name flex-truncate-parent table-mobile-content"> <span - class="flex-truncate-child" v-tooltip :title="model.name" + class="flex-truncate-child" >{{ model.name }}</span> </a> <span v-else class="folder-name" - @click="onClickFolder" - role="button"> + role="button" + @click="onClickFolder"> <span class="folder-icon"> <i @@ -503,11 +503,11 @@ <span v-if="!model.isFolder && deploymentHasUser"> by <user-avatar-link - class="js-deploy-user-container" :link-href="deploymentUser.web_url" :img-src="deploymentUser.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="deploymentUser.username" + class="js-deploy-user-container" /> </span> </div> @@ -518,8 +518,8 @@ > <a v-if="shouldRenderBuildName" - class="build-link flex-truncate-parent" :href="buildPath" + class="build-link flex-truncate-parent" > <span class="flex-truncate-child">{{ buildName }}</span> </a> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue index 8df1b6317e3..947e8c901e9 100644 --- a/app/assets/javascripts/environments/components/environment_monitoring.vue +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -28,16 +28,16 @@ <template> <a v-tooltip - class="btn monitoring-url d-none d-sm-none d-md-block" - data-container="body" - rel="noopener noreferrer nofollow" :href="monitoringUrl" :title="title" :aria-label="title" + class="btn monitoring-url d-none d-sm-none d-md-block" + data-container="body" + rel="noopener noreferrer nofollow" > <icon - name="chart" :size="12" + name="chart" /> </a> </template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 7515d711c50..310835c5ea9 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -39,10 +39,10 @@ </script> <template> <button + :disabled="isLoading" type="button" class="btn d-none d-sm-none d-md-block" @click="onClick" - :disabled="isLoading" > <span v-if="isLastDeployment"> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index 7055f208451..eba58bedd6d 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -40,7 +40,7 @@ methods: { onClick() { // eslint-disable-next-line no-alert - if (confirm('Are you sure you want to stop this environment?')) { + if (window.confirm('Are you sure you want to stop this environment?')) { this.isLoading = true; $(this.$el).tooltip('dispose'); @@ -54,13 +54,13 @@ <template> <button v-tooltip + :disabled="isLoading" + :title="title" + :aria-label="title" type="button" class="btn stop-env-link d-none d-sm-none d-md-block" data-container="body" @click="onClick" - :disabled="isLoading" - :title="title" - :aria-label="title" > <i class="fa fa-stop stop-env-icon" diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 0dbbbb75e07..f8e3165f8cd 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -30,15 +30,15 @@ <template> <a v-tooltip - class="btn terminal-button d-none d-sm-none d-md-block" - data-container="body" :title="title" :aria-label="title" :href="terminalPath" + class="btn terminal-button d-none d-sm-none d-md-block" + data-container="body" > <icon - name="terminal" :size="12" + name="terminal" /> </a> </template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 3da762446c9..b18f02343d6 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -93,8 +93,8 @@ <div class="top-area"> <tabs :tabs="tabs" - @onChangeTab="onChangeTab" scope="environments" + @onChangeTab="onChangeTab" /> <div @@ -119,8 +119,8 @@ @onChangePage="onChangePage" > <empty-state - slot="emptyState" v-if="!isLoading && state.environments.length === 0" + slot="emptyState" :new-path="newEnvironmentPath" :help-path="helpPagePath" :can-create-environment="canCreateEnvironment" diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 5ef5e347387..5f72a39c5cb 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -39,8 +39,8 @@ <template> <div :class="cssContainerClass"> <div - class="top-area" v-if="!isLoading" + class="top-area" > <h4 class="js-folder-name environments-folder-name"> @@ -49,8 +49,8 @@ <tabs :tabs="tabs" - @onChangeTab="onChangeTab" scope="environments" + @onChangeTab="onChangeTab" /> </div> diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 5f2989ab854..5ce9225a4bb 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -146,7 +146,7 @@ export default class EnvironmentsStore { * @return {Array} */ updateEnvironmentProp(environment, prop, newValue) { - const environments = this.state.environments; + const { environments } = this.state; const updatedEnvironments = environments.map((env) => { const updateEnv = Object.assign({}, env); @@ -161,7 +161,7 @@ export default class EnvironmentsStore { } getOpenFolders() { - const environments = this.state.environments; + const { environments } = this.state; return environments.filter(env => env.isFolder && env.isOpen); } diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index 26618af9515..a8eb8d94be3 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -72,9 +72,9 @@ export default { @click="onItemActivated(item.text)"> <span> <span - class="filtered-search-history-dropdown-token" v-for="(token, index) in item.tokens" :key="`dropdown-token-${index}`" + class="filtered-search-history-dropdown-token" > <span class="name">{{ token.prefix }}</span> <span class="value">{{ token.suffix }}</span> diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 9bc36c1f9b6..27fff488603 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -35,7 +35,7 @@ export default class DropdownUtils { // Remove the symbol for filter if (value[0] === filterSymbol) { - symbol = value[0]; + [symbol] = value; value = value.slice(1); } @@ -162,7 +162,7 @@ export default class DropdownUtils { // Determines the full search query (visual tokens + input) static getSearchQuery(untilInput = false) { - const container = FilteredSearchContainer.container; + const { container } = FilteredSearchContainer; const tokens = [].slice.call(container.querySelectorAll('.tokens-container li')); const values = []; @@ -220,7 +220,7 @@ export default class DropdownUtils { } static getInputSelectionPosition(input) { - const selectionStart = input.selectionStart; + const { selectionStart } = input; let inputValue = input.value; // Replace all spaces inside quote marks with underscores // (will continue to match entire string until an end quote is found if any) diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index d7e1de18d09..296571606d6 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -159,7 +159,7 @@ export default class FilteredSearchDropdownManager { load(key, firstLoad = false) { const mappingKey = this.mapping[key]; const glClass = mappingKey.gl; - const element = mappingKey.element; + const { element } = mappingKey; let forceShowList = false; if (!mappingKey.reference) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index cf5ba1e1771..81286c54c4c 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -235,7 +235,7 @@ export default class FilteredSearchManager { checkForEnter(e) { if (e.keyCode === 38 || e.keyCode === 40) { - const selectionStart = this.filteredSearchInput.selectionStart; + const { selectionStart } = this.filteredSearchInput; e.preventDefault(); this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); @@ -496,7 +496,7 @@ export default class FilteredSearchManager { // Replace underscore with hyphen in the sanitizedkey. // e.g. 'my_reaction' => 'my-reaction' sanitizedKey = sanitizedKey.replace('_', '-'); - const symbol = match.symbol; + const { symbol } = match; let quotationsToUse = ''; if (sanitizedValue.indexOf(' ') !== -1) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 600024c21c3..56fe1ab4e90 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -101,7 +101,7 @@ export default class FilteredSearchVisualTokens { static updateLabelTokenColor(tokenValueContainer, tokenValue) { const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); - const baseEndpoint = filteredSearchInput.dataset.baseEndpoint; + const { baseEndpoint } = filteredSearchInput.dataset; const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams( `${baseEndpoint}/labels.json`, filteredSearchInput.dataset.endpointQueryParams, @@ -215,7 +215,7 @@ export default class FilteredSearchVisualTokens { static addFilterVisualToken(tokenName, tokenValue, canEdit) { const { lastVisualToken, isLastVisualTokenValid } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement; + const { addVisualTokenElement } = FilteredSearchVisualTokens; if (isLastVisualTokenValid) { addVisualTokenElement(tokenName, tokenValue, false, canEdit); diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index f9338b82acf..c1efa9c86f4 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -29,7 +29,7 @@ class RecentSearchesRoot { } render() { - const state = this.store.state; + const { state } = this.store; this.vm = new Vue({ el: this.wrapperElement, components: { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 9de57db48fd..73b2cd0b2c7 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -7,6 +7,16 @@ function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); } +export const defaultAutocompleteConfig = { + emojis: true, + members: true, + issues: true, + mergeRequests: true, + epics: true, + milestones: true, + labels: true, +}; + class GfmAutoComplete { constructor(dataSources) { this.dataSources = dataSources || {}; @@ -14,14 +24,7 @@ class GfmAutoComplete { this.isLoadingData = {}; } - setup(input, enableMap = { - emojis: true, - members: true, - issues: true, - milestones: true, - mergeRequests: true, - labels: true, - }) { + setup(input, enableMap = defaultAutocompleteConfig) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); this.enableMap = enableMap; @@ -77,7 +80,7 @@ class GfmAutoComplete { let tpl = '/${name} '; let referencePrefix = null; if (value.params.length > 0) { - referencePrefix = value.params[0][0]; + [[referencePrefix]] = value.params; if (/^[@%~]/.test(referencePrefix)) { tpl += '<%- referencePrefix %>'; } @@ -455,7 +458,7 @@ class GfmAutoComplete { static isLoading(data) { let dataToInspect = data; if (data && data.length > 0) { - dataToInspect = data[0]; + [dataToInspect] = data; } const loadingState = GfmAutoComplete.defaultLoadingData[0]; @@ -490,6 +493,7 @@ GfmAutoComplete.atTypeMap = { '@': 'members', '#': 'issues', '!': 'mergeRequests', + '&': 'epics', '~': 'labels', '%': 'milestones', '/': 'commands', diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 746a06b7c4f..8d231e6c405 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-underscore-dangle, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ +/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, one-var-declaration-per-line, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */ /* global fuzzaldrinPlus */ import $ from 'jquery'; @@ -602,14 +602,18 @@ GitLabDropdown = (function() { var selector; selector = '.dropdown-content'; if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one .dropdown-content"; + if (this.options.containerSelector) { + selector = this.options.containerSelector; + } else { + selector = '.dropdown-page-one .dropdown-content'; + } } return $(selector, this.dropdown).empty(); }; GitLabDropdown.prototype.renderItem = function(data, group, index) { - var field, fieldName, html, selected, text, url, value, rowHidden; + var field, html, selected, text, url, value, rowHidden; if (!this.options.renderRow) { value = this.options.id ? this.options.id(data) : data.id; @@ -647,7 +651,7 @@ GitLabDropdown = (function() { html = this.options.renderRow.call(this.options, data, this); } else { if (!selected) { - fieldName = this.options.fieldName; + const { fieldName } = this.options; if (value) { field = this.dropdown.parent().find(`input[name='${fieldName}'][value='${value}']`); @@ -701,7 +705,8 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.highlightTextMatches = function(text, term) { const occurrences = fuzzaldrinPlus.match(text, term); - const indexOf = [].indexOf; + const { indexOf } = []; + return text.split('').map(function(character, i) { if (indexOf.call(occurrences, i) !== -1) { return "<b>" + character + "</b>"; @@ -717,9 +722,9 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.rowClicked = function(el) { - var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value, isMarking; + var field, groupName, isInput, selectedIndex, selectedObject, value, isMarking; - fieldName = this.options.fieldName; + const { fieldName } = this.options; isInput = $(this.el).is('input'); if (this.renderedData) { groupName = el.data('group'); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 9f5eba353d7..c74de7ac34d 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -1,14 +1,21 @@ import $ from 'jquery'; import autosize from 'autosize'; -import GfmAutoComplete from './gfm_auto_complete'; +import GfmAutoComplete, * as GFMConfig from './gfm_auto_complete'; import dropzoneInput from './dropzone_input'; import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown'; export default class GLForm { - constructor(form, enableGFM = false) { + constructor(form, enableGFM = {}) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); - this.enableGFM = enableGFM; + this.enableGFM = Object.assign({}, GFMConfig.defaultAutocompleteConfig, enableGFM); + // Disable autocomplete for keywords which do not have dataSources available + const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; + Object.keys(this.enableGFM).forEach(item => { + if (item !== 'emojis') { + this.enableGFM[item] = !!dataSources[item]; + } + }); // Before we start, we should clean up any previous data for this form this.destroy(); // Setup the form @@ -34,14 +41,7 @@ export default class GLForm { // remove notify commit author checkbox for non-commit notes gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); - this.autoComplete.setup(this.form.find('.js-gfm-input'), { - emojis: true, - members: this.enableGFM, - issues: this.enableGFM, - milestones: this.enableGFM, - mergeRequests: this.enableGFM, - labels: this.enableGFM, - }); + this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM); dropzoneInput(this.form); autosize(this.textarea); } diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js index 5648cb9a888..d33e3a37580 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/group_label_subscription.js @@ -1,7 +1,12 @@ import $ from 'jquery'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import flash from './flash'; -import { __ } from './locale'; + +const tooltipTitles = { + group: __('Unsubscribe at group level'), + project: __('Unsubscribe at project level'), +}; export default class GroupLabelSubscription { constructor(container) { @@ -35,6 +40,7 @@ export default class GroupLabelSubscription { this.$unsubscribeButtons.attr('data-url', url); axios.post(url) + .then(() => GroupLabelSubscription.setNewTooltip($btn)) .then(() => this.toggleSubscriptionButtons()) .catch(() => flash(__('There was an error when subscribing to this label.'))); } @@ -44,4 +50,14 @@ export default class GroupLabelSubscription { this.$subscribeButtons.toggleClass('hidden'); this.$unsubscribeButtons.toggleClass('hidden'); } + + static setNewTooltip($button) { + if (!$button.hasClass('js-subscribe-button')) return; + + const type = $button.hasClass('js-group-level') ? 'group' : 'project'; + const newTitle = tooltipTitles[type]; + + $('.js-unsubscribe-button', $button.closest('.label-actions-list')) + .tooltip('hide').attr('title', newTitle).tooltip('_fixTitle'); + } } diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 22eb7bd44c5..b0765747a36 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -148,7 +148,6 @@ export default { if (!parentGroup.isOpen) { if (parentGroup.children.length === 0) { parentGroup.isChildrenLoading = true; - // eslint-disable-next-line promise/catch-or-return this.fetchGroups({ parentId: parentGroup.id, }) @@ -216,10 +215,10 @@ export default { <template> <div> <loading-icon - class="loading-animation prepend-top-20" - size="2" v-if="isLoading" :label="s__('GroupsTree|Loading groups')" + class="loading-animation prepend-top-20" + size="2" /> <groups-component v-if="!isLoading" @@ -230,10 +229,10 @@ export default { /> <deprecated-modal v-show="showModal" - kind="warning" :primary-button-label="__('Leave')" :title="__('Are you sure?')" :text="groupLeaveConfirmationMessage" + kind="warning" @cancel="hideLeaveGroupModal" @submit="leaveGroup" /> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 7f64a9bd741..efbf2e3a295 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -71,14 +71,14 @@ export default { <template> <li - @click.stop="onClickRowGroup" :id="groupDomId" :class="rowClass" class="group-row" + @click.stop="onClickRowGroup" > <div - class="group-row-contents" - :class="{ 'project-row-contents': !isGroup }"> + :class="{ 'project-row-contents': !isGroup }" + class="group-row-contents"> <item-actions v-if="isGroup" :group="group" @@ -99,8 +99,8 @@ export default { /> </div> <div - class="avatar-container prepend-top-8 prepend-left-5 s24 d-none d-sm-block" :class="{ 'content-loading': group.isChildrenLoading }" + class="avatar-container prepend-top-8 prepend-left-5 s24 d-none d-sm-block" > <a :href="group.relativePath" @@ -108,14 +108,14 @@ export default { > <img v-if="hasAvatar" - class="avatar s24" :src="group.avatarUrl" + class="avatar s24" /> <identicon v-else - size-class="s24" :entity-id="group.id" :entity-name="group.name" + size-class="s24" /> </a> </div> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 87065b3d6e3..24eec4901ec 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -54,13 +54,13 @@ export default { <a v-tooltip v-if="group.canLeave" - @click.prevent="onLeaveGroup" :href="group.leavePath" :title="leaveBtnTitle" :aria-label="leaveBtnTitle" data-container="body" data-placement="bottom" - class="leave-group btn no-expand"> + class="leave-group btn no-expand" + @click.prevent="onLeaveGroup"> <icon name="leave"/> </a> </div> diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 168b4e4af2c..87ab5480c15 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -45,44 +45,44 @@ <div class="stats"> <item-stats-value v-if="isGroup" - css-class="number-subgroups" - icon-name="folder" :title="__('Subgroups')" :value="item.subgroupCount" + css-class="number-subgroups" + icon-name="folder" /> <item-stats-value v-if="isGroup" - css-class="number-projects" - icon-name="bookmark" :title="__('Projects')" :value="item.projectCount" + css-class="number-projects" + icon-name="bookmark" /> <item-stats-value v-if="isGroup" - css-class="number-users" - icon-name="users" :title="__('Members')" :value="item.memberCount" + css-class="number-users" + icon-name="users" /> <item-stats-value v-if="isProject" + :value="item.starCount" css-class="project-stars" icon-name="star" - :value="item.starCount" /> <item-stats-value - css-class="item-visibility" - tooltip-placement="left" :icon-name="visibilityIcon" :title="visibilityTooltip" + css-class="item-visibility" + tooltip-placement="left" /> <div - class="last-updated" v-if="isProject" + class="last-updated" > <time-ago-tooltip - tooltip-placement="bottom" :time="item.updatedAt" + tooltip-placement="bottom" /> </div> </div> diff --git a/app/assets/javascripts/groups/components/item_stats_value.vue b/app/assets/javascripts/groups/components/item_stats_value.vue index 4d86ac8023c..ef9f2bca76c 100644 --- a/app/assets/javascripts/groups/components/item_stats_value.vue +++ b/app/assets/javascripts/groups/components/item_stats_value.vue @@ -52,10 +52,10 @@ <template> <span v-tooltip - data-container="body" :data-placement="tooltipPlacement" :class="cssClass" :title="title" + data-container="body" > <icon :name="iconName" /> <span diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 57eaac72906..83a9008a94b 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -29,7 +29,7 @@ export default () => { groupsApp, }, data() { - const dataset = this.$options.el.dataset; + const { dataset } = this.$options.el; const hideProjects = dataset.hideProjects === 'true'; const store = new GroupsStore(hideProjects); const service = new GroupsService(dataset.endpoint); @@ -42,7 +42,7 @@ export default () => { }; }, beforeMount() { - const dataset = this.$options.el.dataset; + const { dataset } = this.$options.el; let groupFilterList = null; const form = document.querySelector(dataset.formSel); const filter = document.querySelector(dataset.filterSel); diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index 05dbc1410de..62697e0ecc3 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -1,4 +1,5 @@ <script> +import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -20,6 +21,13 @@ export default { }, methods: { ...mapActions(['updateActivityBarView']), + changedActivityView(e, view) { + e.currentTarget.blur(); + + this.updateActivityBarView(view); + + $(e.currentTarget).tooltip('hide'); + }, }, activityBarViews, }; @@ -31,12 +39,12 @@ export default { <li v-once> <a v-tooltip - data-container="body" - data-placement="right" :href="goBackUrl" - class="ide-sidebar-link" :title="s__('IDE|Go back')" :aria-label="s__('IDE|Go back')" + data-container="body" + data-placement="right" + class="ide-sidebar-link" > <icon :size="16" @@ -47,16 +55,16 @@ export default { <li> <button v-tooltip - data-container="body" - data-placement="right" - type="button" - class="ide-sidebar-link js-ide-edit-mode" :class="{ active: currentActivityView === $options.activityBarViews.edit }" - @click.prevent="updateActivityBarView($options.activityBarViews.edit)" :title="s__('IDE|Edit')" :aria-label="s__('IDE|Edit')" + data-container="body" + data-placement="right" + type="button" + class="ide-sidebar-link js-ide-edit-mode" + @click.prevent="changedActivityView($event, $options.activityBarViews.edit)" > <icon name="code" @@ -66,16 +74,16 @@ export default { <li> <button v-tooltip - data-container="body" - data-placement="right" - type="button" - class="ide-sidebar-link js-ide-review-mode" :class="{ active: currentActivityView === $options.activityBarViews.review }" - @click.prevent="updateActivityBarView($options.activityBarViews.review)" :title="s__('IDE|Review')" :aria-label="s__('IDE|Review')" + data-container="body" + data-placement="right" + type="button" + class="ide-sidebar-link js-ide-review-mode" + @click.prevent="changedActivityView($event, $options.activityBarViews.review)" > <icon name="file-modified" @@ -85,16 +93,16 @@ export default { <li v-show="hasChanges"> <button v-tooltip - data-container="body" - data-placement="right" - type="button" - class="ide-sidebar-link js-ide-commit-mode" :class="{ active: currentActivityView === $options.activityBarViews.commit }" - @click.prevent="updateActivityBarView($options.activityBarViews.commit)" :title="s__('IDE|Commit')" :aria-label="s__('IDE|Commit')" + data-container="body" + data-placement="right" + type="button" + class="ide-sidebar-link js-ide-commit-mode" + @click.prevent="changedActivityView($event, $options.activityBarViews.commit)" > <icon name="commit" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index b4f3778d946..eb7cb9745ec 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -10,7 +10,7 @@ export default { }, computed: { ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']), - ...mapGetters(['currentProject']), + ...mapGetters(['currentProject', 'currentBranch']), commitToCurrentBranchText() { return sprintf( __('Commit to %{branchName} branch'), @@ -22,17 +22,30 @@ export default { return this.changedFiles.length > 0 && this.stagedFiles.length > 0; }, }, + watch: { + disableMergeRequestRadio() { + this.updateSelectedCommitAction(); + }, + }, mounted() { - if (this.disableMergeRequestRadio) { - this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); - } + this.updateSelectedCommitAction(); }, methods: { ...mapActions('commit', ['updateCommitAction']), + updateSelectedCommitAction() { + if (this.currentBranch && !this.currentBranch.can_push) { + this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); + } else if (this.disableMergeRequestRadio) { + this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); + } + }, }, commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, + currentBranchPermissionsTooltip: __( + "This option is disabled as you don't have write permissions for the current branch", + ), }; </script> @@ -40,9 +53,11 @@ export default { <div class="append-bottom-15 ide-commit-radios"> <radio-group :value="$options.commitToCurrentBranch" - :checked="true" + :disabled="currentBranch && !currentBranch.can_push" + :title="$options.currentBranchPermissionsTooltip" > <span + class="ide-radio-label" v-html="commitToCurrentBranchText" > </span> @@ -56,6 +71,7 @@ export default { v-if="currentProject.merge_requests_enabled" :value="$options.commitToNewBranchMR" :label="__('Create a new branch and merge request')" + :title="__('This option is disabled while you still have unstaged changes')" :show-input="true" :disabled="disableMergeRequestRadio" /> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index e2b42ab2642..ee8eb206980 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -24,7 +24,7 @@ export default { ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapGetters(['hasChanges']), - ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), + ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), overviewText() { return sprintf( __( @@ -36,6 +36,9 @@ export default { }, ); }, + commitButtonText() { + return this.stagedFiles.length ? __('Commit') : __('Stage & Commit'); + }, }, watch: { currentActivityView() { @@ -91,7 +94,6 @@ export default { <template> <div - class="multi-file-commit-form" :class="{ 'is-compact': isCompact, 'is-full': !isCompact @@ -99,6 +101,7 @@ export default { :style="{ height: componentHeight ? `${componentHeight}px` : null, }" + class="multi-file-commit-form" > <transition name="commit-form-slide-up" @@ -108,16 +111,16 @@ export default { > <div v-if="isCompact" - class="commit-form-compact" ref="compactEl" + class="commit-form-compact" > <button - type="button" :disabled="!hasChanges" + type="button" class="btn btn-primary btn-sm btn-block" @click="toggleIsSmall" > - {{ __('Commit') }} + {{ __('Commit…') }} </button> <p class="text-center" @@ -126,8 +129,8 @@ export default { </div> <form v-if="!isCompact" - @submit.prevent.stop="commitChanges" ref="formEl" + @submit.prevent.stop="commitChanges" > <transition name="fade"> <success-message @@ -136,15 +139,15 @@ export default { </transition> <commit-message-field :text="commitMessage" + :placeholder="preBuiltCommitMessage" @input="updateCommitMessage" /> <div class="clearfix prepend-top-15"> <actions /> <loading-button :loading="submitCommitLoading" - :disabled="commitButtonDisabled" + :label="commitButtonText" container-class="btn btn-success btn-sm float-left" - :label="__('Commit')" @click="commitChanges" /> <button diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 1325fc993b2..d0fb0e3d99e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -34,6 +34,10 @@ export default { type: String, required: true, }, + actionBtnIcon: { + type: String, + required: true, + }, itemActionComponent: { type: String, required: true, @@ -43,11 +47,15 @@ export default { required: false, default: false, }, - }, - data() { - return { - showActionButton: false, - }; + activeFileKey: { + type: String, + required: false, + default: null, + }, + keyPrefix: { + type: String, + required: true, + }, }, computed: { titleText() { @@ -55,15 +63,15 @@ export default { title: this.title, }); }, + filesLength() { + return this.fileList.length; + }, }, methods: { ...mapActions(['stageAllChanges', 'unstageAllChanges']), actionBtnClicked() { this[this.action](); }, - setShowActionButton(show) { - this.showActionButton = show; - }, }, }; </script> @@ -74,8 +82,6 @@ export default { > <header class="multi-file-commit-panel-header" - @mouseenter="setShowActionButton(true)" - @mouseleave="setShowActionButton(false)" > <div class="multi-file-commit-panel-header-title" @@ -86,24 +92,40 @@ export default { :size="18" /> {{ titleText }} - <span - v-show="!showActionButton" - class="ide-commit-file-count" - > - {{ fileList.length }} - </span> - <button - v-show="showActionButton" - type="button" - class="btn btn-blank btn-link ide-staged-action-btn" - @click="actionBtnClicked" - > - {{ actionBtnText }} - </button> + <div class="d-flex ml-auto"> + <button + v-tooltip + v-show="filesLength" + :class="{ + 'd-flex': filesLength + }" + :title="actionBtnText" + type="button" + class="btn btn-default ide-staged-action-btn p-0 order-1 align-items-center" + data-placement="bottom" + data-container="body" + data-boundary="viewport" + @click="actionBtnClicked" + > + <icon + :name="actionBtnIcon" + :size="12" + class="ml-auto mr-auto" + /> + </button> + <span + :class="{ + 'rounded-right': !filesLength + }" + class="ide-commit-file-count order-0 rounded-left text-center" + > + {{ filesLength }} + </span> + </div> </div> </header> <ul - v-if="fileList.length" + v-if="filesLength" class="multi-file-commit-list list-unstyled append-bottom-0" > <li @@ -113,8 +135,9 @@ export default { <list-item :file="file" :action-component="itemActionComponent" - :key-prefix="title" + :key-prefix="keyPrefix" :staged-list="stagedList" + :active-file-key="activeFileKey" /> </li> </ul> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index 2254271c679..d376a004e84 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -38,14 +38,17 @@ export default { return this.modifiedFilesLength ? 'multi-file-modified' : ''; }, additionsTooltip() { - return sprintf(n__('1 %{type} addition', '%d %{type} additions', this.addedFilesLength), { + return sprintf(n__('1 %{type} addition', '%{count} %{type} additions', this.addedFilesLength), { type: this.title.toLowerCase(), + count: this.addedFilesLength, }); }, modifiedTooltip() { return sprintf( - n__('1 %{type} modification', '%d %{type} modifications', this.modifiedFilesLength), - { type: this.title.toLowerCase() }, + n__('1 %{type} modification', '%{count} %{type} modifications', this.modifiedFilesLength), { + type: this.title.toLowerCase(), + count: this.modifiedFilesLength, + }, ); }, titleTooltip() { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 03f3e4de83c..ee21eeda3cd 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,5 +1,6 @@ <script> import { mapActions } from 'vuex'; +import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import StageButton from './stage_button.vue'; import UnstageButton from './unstage_button.vue'; @@ -11,6 +12,9 @@ export default { StageButton, UnstageButton, }, + directives: { + tooltip, + }, props: { file: { type: Object, @@ -30,6 +34,11 @@ export default { required: false, default: false, }, + activeFileKey: { + type: String, + required: false, + default: null, + }, }, computed: { iconName() { @@ -39,6 +48,15 @@ export default { iconClass() { return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; }, + fullKey() { + return `${this.keyPrefix}-${this.file.key}`; + }, + isActive() { + return this.activeFileKey === this.fullKey; + }, + tooltipTitle() { + return this.file.path === this.file.name ? '' : this.file.path; + }, }, methods: { ...mapActions([ @@ -51,7 +69,7 @@ export default { openFileInEditor() { return this.openPendingTab({ file: this.file, - keyPrefix: this.keyPrefix.toLowerCase(), + keyPrefix: this.keyPrefix, }).then(changeViewer => { if (changeViewer) { this.updateViewer(viewerTypes.diff); @@ -70,24 +88,30 @@ export default { </script> <template> - <div class="multi-file-commit-list-item"> - <button - type="button" - class="multi-file-commit-list-path" + <div class="multi-file-commit-list-item position-relative"> + <div + v-tooltip + :title="tooltipTitle" + :class="{ + 'is-active': isActive + }" + class="multi-file-commit-list-path w-100 border-0 ml-0 mr-0" + role="button" @dblclick="fileAction" @click="openFileInEditor" > - <span class="multi-file-commit-list-file-path"> + <span class="multi-file-commit-list-file-path d-flex align-items-center"> <icon :name="iconName" :size="16" :css-classes="iconClass" - />{{ file.path }} + />{{ file.name }} </span> - </button> + </div> <component :is="actionComponent" :path="file.path" + class="d-flex position-absolute" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index f14fcdc88ed..37ca108fafc 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -16,6 +16,10 @@ export default { type: String, required: true, }, + placeholder: { + type: String, + required: true, + }, }, data() { return { @@ -54,7 +58,7 @@ export default { placement: 'top', content: sprintf( __(` - The character highligher helps you keep the subject line to %{titleLength} characters + The character highlighter helps you keep the subject line to %{titleLength} characters and wrap the body at %{bodyLength} so they are readable in git. `), { titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH }, @@ -66,10 +70,10 @@ export default { <template> <fieldset class="common-note-form ide-commit-message-field"> <div - class="md-area" :class="{ 'is-focused': isFocused }" + class="md-area" > <div v-once @@ -92,10 +96,10 @@ export default { <div class="ide-commit-message-textarea-container"> <div class="ide-commit-message-highlights-container"> <div - class="note-textarea highlights monospace" :style="{ transform: `translate3d(0, ${-scrollTop}px, 0)` }" + class="note-textarea highlights monospace" > <div v-for="(line, index) in allLines" @@ -113,15 +117,15 @@ export default { </div> </div> <textarea + ref="textarea" + :placeholder="placeholder" + :value="text" class="note-textarea ide-commit-message-textarea" name="commit-message" - :placeholder="__('Write a commit message...')" - :value="text" @scroll="handleScroll" @input="onInput" @focus="updateIsFocused(true)" @blur="updateIsFocused(false)" - ref="textarea" > </textarea> </div> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 00f2312ae51..969e2aa61c4 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -1,6 +1,5 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; -import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; export default { @@ -32,14 +31,17 @@ export default { required: false, default: false, }, + title: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState('commit', ['commitAction']), ...mapGetters('commit', ['newBranchName']), tooltipTitle() { - return this.disabled - ? __('This option is disabled while you still have unstaged changes') - : ''; + return this.disabled ? this.title : ''; }, }, methods: { @@ -58,12 +60,12 @@ export default { }" > <input - type="radio" - name="commit-action" :value="value" - @change="updateCommitAction($event.target.value)" :checked="commitAction === value" :disabled="disabled" + type="radio" + name="commit-action" + @change="updateCommitAction($event.target.value)" /> <span class="prepend-left-10"> <span @@ -80,9 +82,9 @@ export default { class="ide-commit-new-branch" > <input + :placeholder="newBranchName" type="text" class="form-control monospace" - :placeholder="newBranchName" @input="updateBranchName($event.target.value)" /> </div> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue index 52dce8412ab..7014b9f605e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue @@ -25,35 +25,51 @@ export default { <template> <div v-once - class="multi-file-discard-btn" + class="multi-file-discard-btn dropdown" > <button v-tooltip - type="button" - class="btn btn-blank append-right-5" :aria-label="__('Stage changes')" :title="__('Stage changes')" + type="button" + class="btn btn-blank append-right-5 d-flex align-items-center" data-container="body" + data-boundary="viewport" + data-placement="bottom" @click.stop="stageChange(path)" > <icon - name="mobile-issue-close" :size="12" + name="mobile-issue-close" /> </button> <button v-tooltip + :title="__('More actions')" type="button" - class="btn btn-blank" - :aria-label="__('Discard changes')" - :title="__('Discard changes')" + class="btn btn-blank d-flex align-items-center" data-container="body" - @click.stop="discardFileChanges(path)" + data-boundary="viewport" + data-placement="bottom" + data-toggle="dropdown" + data-display="static" > <icon - name="remove" :size="12" + name="more" /> </button> + <div class="dropdown-menu dropdown-menu-right"> + <ul> + <li> + <button + type="button" + @click.stop="discardFileChanges(path)" + > + {{ __('Discard changes') }} + </button> + </li> + </ul> + </div> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue index 123d60da47e..9cec73ec00e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue @@ -29,16 +29,18 @@ export default { > <button v-tooltip - type="button" - class="btn btn-blank" :aria-label="__('Unstage changes')" :title="__('Unstage changes')" + type="button" + class="btn btn-blank d-flex align-items-center" data-container="body" + data-boundary="viewport" + data-placement="bottom" @click="unstageChange(path)" > <icon - name="history" :size="12" + name="history" /> </button> </div> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index b9af4d27145..95598c9aca6 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -44,11 +44,11 @@ export default { <ul> <li> <a - href="#" - @click.prevent="changeMode($options.viewerTypes.mr)" :class="{ 'is-active': viewer === $options.viewerTypes.mr, }" + href="#" + @click.prevent="changeMode($options.viewerTypes.mr)" > <strong class="dropdown-menu-inner-title"> {{ mergeReviewLine }} @@ -60,11 +60,11 @@ export default { </li> <li> <a - href="#" - @click.prevent="changeMode($options.viewerTypes.diff)" :class="{ 'is-active': viewer === $options.viewerTypes.diff, }" + href="#" + @click.prevent="changeMode($options.viewerTypes.diff)" > <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong> <span class="dropdown-menu-inner-content"> diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue new file mode 100644 index 00000000000..acbc98b7a7b --- /dev/null +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -0,0 +1,69 @@ +<script> +import { mapActions } from 'vuex'; +import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + components: { + LoadingIcon, + }, + props: { + message: { + type: Object, + required: true, + }, + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + ...mapActions(['setErrorMessage']), + clickAction() { + if (this.isLoading) return; + + this.isLoading = true; + + this.message + .action(this.message.actionPayload) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + }); + }, + clickFlash() { + if (!this.message.action) { + this.setErrorMessage(null); + } + }, + }, +}; +</script> + +<template> + <div + class="flash-container flash-container-page" + @click="clickFlash" + > + <div class="flash-alert"> + <span + v-html="message.text" + > + </span> + <button + v-if="message.action" + type="button" + class="flash-action text-white p-0 border-top-0 border-right-0 border-left-0 bg-transparent" + @click.stop.prevent="clickAction" + > + {{ message.actionText }} + <loading-icon + v-show="isLoading" + inline + /> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue index cf3316a8179..e24fe5bbccb 100644 --- a/app/assets/javascripts/ide/components/external_link.vue +++ b/app/assets/javascripts/ide/components/external_link.vue @@ -26,15 +26,15 @@ export default { > <a :href="file.permalink" - target="_blank" :title="s__('IDE|Open in file view')" + target="_blank" rel="noopener noreferrer" > <span class="vertical-align-middle">Open in file view</span> <icon + :size="16" name="external-link" css-classes="vertical-align-middle space-right" - :size="16" /> </a> </div> diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/ide/components/file_finder/index.vue index cabb3f59b17..0ba33053717 100644 --- a/app/assets/javascripts/ide/components/file_finder/index.vue +++ b/app/assets/javascripts/ide/components/file_finder/index.vue @@ -173,38 +173,38 @@ export default { > <div class="dropdown-input"> <input + ref="searchInput" + :placeholder="__('Search files')" + v-model="searchText" type="search" class="dropdown-input-field" - :placeholder="__('Search files')" autocomplete="off" - v-model="searchText" - ref="searchInput" @keydown="onKeydown($event)" @keyup="onKeyup($event)" /> <i - aria-hidden="true" - class="fa fa-search dropdown-input-search" :class="{ hidden: showClearInputButton }" + aria-hidden="true" + class="fa fa-search dropdown-input-search" ></i> <i - role="button" :aria-label="__('Clear search input')" - class="fa fa-times dropdown-input-clear" :class="{ show: showClearInputButton }" + role="button" + class="fa fa-times dropdown-input-clear" @click="clearSearchInput" ></i> </div> <div> <virtual-list + ref="virtualScrollList" :size="listHeight" :remain="listShowCount" wtag="ul" - ref="virtualScrollList" > <template v-if="filteredBlobsLength"> <li @@ -212,11 +212,11 @@ export default { :key="file.key" > <item - class="disable-hover" :file="file" :search-text="searchText" :focused="index === focusedIndex" :index="index" + class="disable-hover" @click="openFile" @mouseover="onMouseOver" @mousemove="onMouseMove" diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/ide/components/file_finder/item.vue index d4427420207..f5252ce7706 100644 --- a/app/assets/javascripts/ide/components/file_finder/item.vue +++ b/app/assets/javascripts/ide/components/file_finder/item.vue @@ -30,7 +30,7 @@ export default { }, computed: { pathWithEllipsis() { - const path = this.file.path; + const { path } = this.file; return path.length < MAX_PATH_LENGTH ? path @@ -59,11 +59,11 @@ export default { <template> <button - type="button" - class="diff-changed-file" :class="{ 'is-focused': focused, }" + type="button" + class="diff-changed-file" @click.prevent="clickRow" @mouseover="mouseOverRow" @mousemove="mouseMove" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index f5f832521c5..9f016e0338f 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -7,6 +7,7 @@ import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; +import ErrorMessage from './error_message.vue'; const originalStopCallback = Mousetrap.stopCallback; @@ -18,6 +19,7 @@ export default { RepoEditor, FindFile, RightPane, + ErrorMessage, }, computed: { ...mapState([ @@ -28,6 +30,7 @@ export default { 'fileFindVisible', 'emptyStateSvgPath', 'currentProjectId', + 'errorMessage', ]), ...mapGetters(['activeFile', 'hasChanges']), }, @@ -72,6 +75,10 @@ export default { <template> <article class="ide"> + <error-message + v-if="errorMessage" + :message="errorMessage" + /> <div class="ide-view" > @@ -93,8 +100,8 @@ export default { :merge-request-id="currentMergeRequestId" /> <repo-editor - class="multi-file-edit-pane-content" :file="activeFile" + class="multi-file-edit-pane-content" /> </template> <template diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue index 0c9ec3b00f0..f9978762c45 100644 --- a/app/assets/javascripts/ide/components/ide_review.vue +++ b/app/assets/javascripts/ide/components/ide_review.vue @@ -11,17 +11,20 @@ export default { }, computed: { ...mapGetters(['currentMergeRequest']), - ...mapState(['viewer']), + ...mapState(['viewer', 'currentMergeRequestId']), showLatestChangesText() { - return !this.currentMergeRequest || this.viewer === viewerTypes.diff; + return !this.currentMergeRequestId || this.viewer === viewerTypes.diff; }, showMergeRequestText() { - return this.currentMergeRequest && this.viewer === viewerTypes.mr; + return this.currentMergeRequestId && this.viewer === viewerTypes.mr; + }, + mergeRequestId() { + return `!${this.currentMergeRequest.iid}`; }, }, mounted() { this.$nextTick(() => { - this.updateViewer(this.currentMergeRequest ? viewerTypes.mr : viewerTypes.diff); + this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff); }); }, methods: { @@ -33,8 +36,8 @@ export default { <template> <ide-tree-list :viewer-type="viewer" - header-class="ide-review-header" :disable-action-dropdown="true" + header-class="ide-review-header" > <template slot="header" @@ -54,7 +57,11 @@ export default { </template> <template v-else-if="showMergeRequestText"> {{ __('Merge request') }} - (<a :href="currentMergeRequest.web_url">!{{ currentMergeRequest.iid }}</a>) + (<a + v-if="currentMergeRequest" + :href="currentMergeRequest.web_url" + v-text="mergeRequestId" + ></a>) </template> </div> </template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 3f980203911..21906674c4b 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -1,4 +1,5 @@ <script> +import $ from 'jquery'; import { mapState, mapGetters } from 'vuex'; import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -13,6 +14,7 @@ import CommitSection from './repo_commit_section.vue'; import CommitForm from './commit_sidebar/form.vue'; import IdeReview from './ide_review.vue'; import SuccessMessage from './commit_sidebar/success_message.vue'; +import MergeRequestDropdown from './merge_requests/dropdown.vue'; import { activityBarViews } from '../constants'; export default { @@ -32,10 +34,12 @@ export default { CommitForm, IdeReview, SuccessMessage, + MergeRequestDropdown, }, data() { return { showTooltip: false, + showMergeRequestsDropdown: false, }; }, computed: { @@ -46,6 +50,7 @@ export default { 'changedFiles', 'stagedFiles', 'lastCommitMsg', + 'currentMergeRequestId', ]), ...mapGetters(['currentProject', 'someUncommitedChanges']), showSuccessMessage() { @@ -61,9 +66,39 @@ export default { watch: { currentBranchId() { this.$nextTick(() => { + if (!this.$refs.branchId) return; + this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth; }); }, + loading() { + this.$nextTick(() => { + this.addDropdownListeners(); + }); + }, + }, + mounted() { + this.addDropdownListeners(); + }, + beforeDestroy() { + $(this.$refs.mergeRequestDropdown) + .off('show.bs.dropdown') + .off('hide.bs.dropdown'); + }, + methods: { + addDropdownListeners() { + if (!this.$refs.mergeRequestDropdown) return; + + $(this.$refs.mergeRequestDropdown) + .on('show.bs.dropdown', () => { + this.toggleMergeRequestDropdown(); + }).on('hide.bs.dropdown', () => { + this.toggleMergeRequestDropdown(); + }); + }, + toggleMergeRequestDropdown() { + this.showMergeRequestsDropdown = !this.showMergeRequestsDropdown; + }, }, }; </script> @@ -80,53 +115,79 @@ export default { <div class="multi-file-commit-panel-inner"> <template v-if="loading"> <div - class="multi-file-loading-container" v-for="n in 3" :key="n" + class="multi-file-loading-container" > <skeleton-loading-container /> </div> </template> <template v-else> - <div class="context-header ide-context-header"> - <a - :href="currentProject.web_url" + <div + ref="mergeRequestDropdown" + class="context-header ide-context-header dropdown" + > + <button + type="button" + data-toggle="dropdown" > <div v-if="currentProject.avatar_url" class="avatar-container s40 project-avatar" > <project-avatar-image - class="avatar-container project-avatar" :link-href="currentProject.path" :img-src="currentProject.avatar_url" :img-alt="currentProject.name" :img-size="40" + class="avatar-container project-avatar" /> </div> <identicon v-else - size-class="s40" :entity-id="currentProject.id" :entity-name="currentProject.name" + size-class="s40" /> <div class="ide-sidebar-project-title"> <div class="sidebar-context-title"> {{ currentProject.name }} </div> - <div - class="sidebar-context-title ide-sidebar-branch-title" - ref="branchId" - v-tooltip - :title="branchTooltipTitle" - > - <icon - name="branch" - css-classes="append-right-5" - />{{ currentBranchId }} + <div class="d-flex"> + <div + v-tooltip + v-if="currentBranchId" + ref="branchId" + :title="branchTooltipTitle" + class="sidebar-context-title ide-sidebar-branch-title" + > + <icon + name="branch" + css-classes="append-right-5" + />{{ currentBranchId }} + </div> + <div + v-if="currentMergeRequestId" + :class="{ + 'prepend-left-8': currentBranchId + }" + class="sidebar-context-title ide-sidebar-branch-title" + > + <icon + name="git-merge" + css-classes="append-right-5" + />!{{ currentMergeRequestId }} + </div> </div> </div> - </a> + <icon + class="ml-auto" + name="chevron-down" + /> + </button> + <merge-request-dropdown + :show="showMergeRequestsDropdown" + /> </div> <div class="multi-file-commit-panel-inner-scroll"> <component diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 368a2995ed9..0582ad32e92 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -35,9 +35,7 @@ export default { }, watch: { lastCommit() { - if (!this.isPollingInitialized) { - this.initPipelinePolling(); - } + this.initPipelinePolling(); }, }, mounted() { @@ -47,9 +45,8 @@ export default { if (this.intervalId) { clearInterval(this.intervalId); } - if (this.isPollingInitialized) { - this.stopPipelinePolling(); - } + + this.stopPipelinePolling(); }, methods: { ...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']), @@ -59,8 +56,9 @@ export default { }, 1000); }, initPipelinePolling() { - this.fetchLatestPipeline(); - this.isPollingInitialized = true; + if (this.lastCommit) { + this.fetchLatestPipeline(); + } }, commitAgeUpdate() { if (this.lastCommit) { @@ -77,22 +75,22 @@ export default { <template> <footer class="ide-status-bar"> <div - class="ide-status-branch" v-if="lastCommit && lastCommitFormatedAge" + class="ide-status-branch" > <span - class="ide-status-pipeline" v-if="latestPipeline && latestPipeline.details" + class="ide-status-pipeline" > <ci-icon - :status="latestPipeline.details.status" v-tooltip + :status="latestPipeline.details.status" :title="latestPipeline.details.status.text" /> Pipeline <a - class="monospace" - :href="latestPipeline.details.status.details_path">#{{ latestPipeline.id }}</a> + :href="latestPipeline.details.status.details_path" + class="monospace">#{{ latestPipeline.id }}</a> {{ latestPipeline.details.status.text }} for </span> @@ -102,18 +100,18 @@ export default { /> <a v-tooltip - class="commit-sha" :title="lastCommit.message" :href="getCommitPath(lastCommit.short_id)" + class="commit-sha" >{{ lastCommit.short_id }}</a> by {{ lastCommit.author_name }} <time v-tooltip - data-placement="top" - data-container="body" :datetime="lastCommit.committed_date" :title="tooltipTitle(lastCommit.committed_date)" + data-placement="top" + data-container="body" > {{ lastCommitFormatedAge }} </time> @@ -131,8 +129,8 @@ export default { {{ file.eol }} </div> <div - class="ide-status-file" - v-if="file && !file.binary"> + v-if="file && !file.binary" + class="ide-status-file"> {{ file.editorRow }}:{{ file.editorColumn }} </div> <div diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index e64a09fcc90..0df99798d21 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -50,17 +50,17 @@ export default { > <template v-if="showLoading"> <div - class="multi-file-loading-container" v-for="n in 3" :key="n" + class="multi-file-loading-container" > <skeleton-loading-container /> </div> </template> <template v-else> <header - class="ide-tree-header" :class="headerClass" + class="ide-tree-header" > <slot name="header"></slot> </header> diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue new file mode 100644 index 00000000000..f39ce545656 --- /dev/null +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -0,0 +1,137 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import _ from 'underscore'; +import { __ } from '../../../locale'; +import tooltip from '../../../vue_shared/directives/tooltip'; +import Icon from '../../../vue_shared/components/icon.vue'; +import ScrollButton from './detail/scroll_button.vue'; +import JobDescription from './detail/description.vue'; + +const scrollPositions = { + top: 0, + bottom: 1, +}; + +export default { + directives: { + tooltip, + }, + components: { + Icon, + ScrollButton, + JobDescription, + }, + data() { + return { + scrollPos: scrollPositions.top, + }; + }, + computed: { + ...mapState('pipelines', ['detailJob']), + isScrolledToBottom() { + return this.scrollPos === scrollPositions.bottom; + }, + isScrolledToTop() { + return this.scrollPos === scrollPositions.top; + }, + jobOutput() { + return this.detailJob.output || __('No messages were logged'); + }, + }, + mounted() { + this.getTrace(); + }, + methods: { + ...mapActions('pipelines', ['fetchJobTrace', 'setDetailJob']), + scrollDown() { + if (this.$refs.buildTrace) { + this.$refs.buildTrace.scrollTo(0, this.$refs.buildTrace.scrollHeight); + } + }, + scrollUp() { + if (this.$refs.buildTrace) { + this.$refs.buildTrace.scrollTo(0, 0); + } + }, + scrollBuildLog: _.throttle(function buildLogScrollDebounce() { + const { scrollTop } = this.$refs.buildTrace; + const { offsetHeight, scrollHeight } = this.$refs.buildTrace; + + if (scrollTop + offsetHeight === scrollHeight) { + this.scrollPos = scrollPositions.bottom; + } else if (scrollTop === 0) { + this.scrollPos = scrollPositions.top; + } else { + this.scrollPos = ''; + } + }), + getTrace() { + return this.fetchJobTrace().then(() => this.scrollDown()); + }, + }, +}; +</script> + +<template> + <div class="ide-pipeline build-page d-flex flex-column flex-fill"> + <header class="ide-job-header d-flex align-items-center"> + <button + class="btn btn-default btn-sm d-flex" + @click="setDetailJob(null)" + > + <icon + name="chevron-left" + /> + {{ __('View jobs') }} + </button> + </header> + <div class="top-bar d-flex border-left-0"> + <job-description + :job="detailJob" + /> + <div class="controllers ml-auto"> + <a + v-tooltip + :title="__('Show complete raw log')" + :href="detailJob.rawPath" + data-placement="top" + data-container="body" + class="controllers-buttons" + target="_blank" + > + <i + aria-hidden="true" + class="fa fa-file-text-o" + ></i> + </a> + <scroll-button + :disabled="isScrolledToTop" + direction="up" + @click="scrollUp" + /> + <scroll-button + :disabled="isScrolledToBottom" + direction="down" + @click="scrollDown" + /> + </div> + </div> + <pre + ref="buildTrace" + class="build-trace mb-0 h-100" + @scroll="scrollBuildLog" + > + <code + v-show="!detailJob.isLoading" + class="bash" + v-html="jobOutput" + > + </code> + <div + v-show="detailJob.isLoading" + class="build-loader-animation" + > + </div> + </pre> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue new file mode 100644 index 00000000000..7e24974f7e5 --- /dev/null +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -0,0 +1,47 @@ +<script> +import Icon from '../../../../vue_shared/components/icon.vue'; +import CiIcon from '../../../../vue_shared/components/ci_icon.vue'; + +export default { + components: { + Icon, + CiIcon, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + jobId() { + return `#${this.job.id}`; + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center"> + <ci-icon + :status="job.status" + :borderless="true" + :size="24" + class="d-flex" + /> + <span class="prepend-left-8"> + {{ job.name }} + <a + :href="job.path" + target="_blank" + class="ide-external-link" + > + {{ jobId }} + <icon + :size="12" + name="external-link" + /> + </a> + </span> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue new file mode 100644 index 00000000000..103a407987f --- /dev/null +++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue @@ -0,0 +1,66 @@ +<script> +import { __ } from '../../../../locale'; +import Icon from '../../../../vue_shared/components/icon.vue'; +import tooltip from '../../../../vue_shared/directives/tooltip'; + +const directions = { + up: 'up', + down: 'down', +}; + +export default { + directives: { + tooltip, + }, + components: { + Icon, + }, + props: { + direction: { + type: String, + required: true, + validator(value) { + return Object.keys(directions).includes(value); + }, + }, + disabled: { + type: Boolean, + required: true, + }, + }, + computed: { + tooltipTitle() { + return this.direction === directions.up ? __('Scroll to top') : __('Scroll to bottom'); + }, + iconName() { + return `scroll_${this.direction}`; + }, + }, + methods: { + clickedScroll() { + this.$emit('click'); + }, + }, +}; +</script> + +<template> + <div + v-tooltip + :title="tooltipTitle" + class="controllers-buttons" + data-container="body" + data-placement="top" + > + <button + :disabled="disabled" + class="btn-scroll btn-transparent btn-blank" + type="button" + @click="clickedScroll" + > + <icon + :name="iconName" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue index c33936021d4..7f4695a0451 100644 --- a/app/assets/javascripts/ide/components/jobs/item.vue +++ b/app/assets/javascripts/ide/components/jobs/item.vue @@ -1,11 +1,9 @@ <script> -import Icon from '../../../vue_shared/components/icon.vue'; -import CiIcon from '../../../vue_shared/components/ci_icon.vue'; +import JobDescription from './detail/description.vue'; export default { components: { - Icon, - CiIcon, + JobDescription, }, props: { job: { @@ -18,29 +16,29 @@ export default { return `#${this.job.id}`; }, }, + methods: { + clickViewLog() { + this.$emit('clickViewLog', this.job); + }, + }, }; </script> <template> <div class="ide-job-item"> - <ci-icon - :status="job.status" - :borderless="true" - :size="24" + <job-description + :job="job" + class="append-right-default" /> - <span class="prepend-left-8"> - {{ job.name }} - <a - :href="job.path" - target="_blank" - class="ide-external-link" + <div class="ml-auto align-self-center"> + <button + v-if="job.started" + type="button" + class="btn btn-default btn-sm" + @click="clickViewLog" > - {{ jobId }} - <icon - name="external-link" - :size="12" - /> - </a> - </span> + {{ __('View log') }} + </button> + </div> </div> </template> diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue index bdd0364c9b9..3b16b860ecd 100644 --- a/app/assets/javascripts/ide/components/jobs/list.vue +++ b/app/assets/javascripts/ide/components/jobs/list.vue @@ -19,7 +19,7 @@ export default { }, }, methods: { - ...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed']), + ...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed', 'setDetailJob']), }, }; </script> @@ -38,6 +38,7 @@ export default { :stage="stage" @fetch="fetchJobs" @toggleCollapsed="toggleStageCollapsed" + @clickViewLog="setDetailJob" /> </template> </div> diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 5b24bb1f5a7..15e881b7bc8 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -48,6 +48,9 @@ export default { toggleCollapsed() { this.$emit('toggleCollapsed', this.stage.id); }, + clickViewLog(job) { + this.$emit('clickViewLog', job); + }, }, }; </script> @@ -57,10 +60,10 @@ export default { class="ide-stage card prepend-top-default" > <div - class="card-header" :class="{ 'border-bottom-0': stage.isCollapsed }" + class="card-header" @click="toggleCollapsed" > <ci-icon @@ -69,10 +72,10 @@ export default { /> <strong v-tooltip="showTooltip" + ref="stageTitle" :title="showTooltip ? stage.name : null" data-container="body" class="prepend-left-8 ide-stage-title" - ref="stageTitle" > {{ stage.name }} </strong> @@ -90,8 +93,8 @@ export default { /> </div> <div - class="card-body" v-show="!stage.isCollapsed" + class="card-body" > <loading-icon v-if="showLoadingIcon" @@ -101,6 +104,7 @@ export default { v-for="job in stage.jobs" :key="job.id" :job="job" + @clickViewLog="clickViewLog" /> </template> </div> diff --git a/app/assets/javascripts/ide/components/merge_requests/dropdown.vue b/app/assets/javascripts/ide/components/merge_requests/dropdown.vue new file mode 100644 index 00000000000..4b9824bf04b --- /dev/null +++ b/app/assets/javascripts/ide/components/merge_requests/dropdown.vue @@ -0,0 +1,63 @@ +<script> +import { mapGetters } from 'vuex'; +import Tabs from '../../../vue_shared/components/tabs/tabs'; +import Tab from '../../../vue_shared/components/tabs/tab.vue'; +import List from './list.vue'; + +export default { + components: { + Tabs, + Tab, + List, + }, + props: { + show: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters('mergeRequests', ['assignedData', 'createdData']), + createdMergeRequestLength() { + return this.createdData.mergeRequests.length; + }, + assignedMergeRequestLength() { + return this.assignedData.mergeRequests.length; + }, + }, +}; +</script> + +<template> + <div class="dropdown-menu ide-merge-requests-dropdown p-0"> + <tabs + v-if="show" + stop-propagation + > + <tab active> + <template slot="title"> + {{ __('Created by me') }} + <span class="badge badge-pill"> + {{ createdMergeRequestLength }} + </span> + </template> + <list + :empty-text="__('You have not created any merge requests')" + type="created" + /> + </tab> + <tab> + <template slot="title"> + {{ __('Assigned to me') }} + <span class="badge badge-pill"> + {{ assignedMergeRequestLength }} + </span> + </template> + <list + :empty-text="__('You do not have any assigned merge requests')" + type="assigned" + /> + </tab> + </tabs> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue new file mode 100644 index 00000000000..4e18376bd48 --- /dev/null +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -0,0 +1,63 @@ +<script> +import Icon from '../../../vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + }, + props: { + item: { + type: Object, + required: true, + }, + currentId: { + type: String, + required: true, + }, + currentProjectId: { + type: String, + required: true, + }, + }, + computed: { + isActive() { + return ( + this.item.iid === parseInt(this.currentId, 10) && + this.currentProjectId === this.item.projectPathWithNamespace + ); + }, + pathWithID() { + return `${this.item.projectPathWithNamespace}!${this.item.iid}`; + }, + }, + methods: { + clickItem() { + this.$emit('click', this.item); + }, + }, +}; +</script> + +<template> + <button + type="button" + class="btn-link d-flex align-items-center" + @click="clickItem" + > + <span class="d-flex append-right-default ide-merge-request-current-icon"> + <icon + v-if="isActive" + :size="18" + name="mobile-issue-close" + /> + </span> + <span> + <strong> + {{ item.title }} + </strong> + <span class="ide-merge-request-project-path d-block mt-1"> + {{ pathWithID }} + </span> + </span> + </button> +</template> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue new file mode 100644 index 00000000000..19d3e48ee10 --- /dev/null +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -0,0 +1,132 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import _ from 'underscore'; +import LoadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import Item from './item.vue'; + +export default { + components: { + LoadingIcon, + Item, + }, + props: { + type: { + type: String, + required: true, + }, + emptyText: { + type: String, + required: true, + }, + }, + data() { + return { + search: '', + }; + }, + computed: { + ...mapGetters('mergeRequests', ['getData']), + ...mapState(['currentMergeRequestId', 'currentProjectId']), + data() { + return this.getData(this.type); + }, + isLoading() { + return this.data.isLoading; + }, + mergeRequests() { + return this.data.mergeRequests; + }, + hasMergeRequests() { + return this.mergeRequests.length !== 0; + }, + hasNoSearchResults() { + return this.search !== '' && !this.hasMergeRequests; + }, + }, + watch: { + isLoading: { + handler: 'focusSearch', + }, + }, + mounted() { + this.loadMergeRequests(); + }, + methods: { + ...mapActions('mergeRequests', ['fetchMergeRequests', 'openMergeRequest']), + loadMergeRequests() { + this.fetchMergeRequests({ type: this.type, search: this.search }); + }, + viewMergeRequest(item) { + this.openMergeRequest({ + projectPath: item.projectPathWithNamespace, + id: item.iid, + }); + }, + searchMergeRequests: _.debounce(function debounceSearch() { + this.loadMergeRequests(); + }, 250), + focusSearch() { + if (!this.isLoading) { + this.$nextTick(() => { + this.$refs.searchInput.focus(); + }); + } + }, + }, +}; +</script> + +<template> + <div> + <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom"> + <input + ref="searchInput" + :placeholder="__('Search merge requests')" + v-model="search" + type="search" + class="dropdown-input-field" + @input="searchMergeRequests" + /> + <i + aria-hidden="true" + class="fa fa-search dropdown-input-search" + ></i> + </div> + <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> + <loading-icon + v-if="isLoading" + class="mt-3 mb-3 align-self-center ml-auto mr-auto" + size="2" + /> + <ul + v-else + class="mb-3 w-100" + > + <template v-if="hasMergeRequests"> + <li + v-for="item in mergeRequests" + :key="item.id" + > + <item + :item="item" + :current-id="currentMergeRequestId" + :current-project-id="currentProjectId" + @click="viewMergeRequest" + /> + </li> + </template> + <li + v-else + class="ide-merge-requests-empty d-flex align-items-center justify-content-center" + > + <template v-if="hasNoSearchResults"> + {{ __('No merge requests found') }} + </template> + <template v-else> + {{ emptyText }} + </template> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue index 179a589d1ac..821be319cce 100644 --- a/app/assets/javascripts/ide/components/mr_file_icon.vue +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -14,10 +14,10 @@ export default { <template> <icon - name="git-merge" v-tooltip :title="__('Part of merge request changes')" - css-classes="append-right-8" :size="12" + name="git-merge" + css-classes="append-right-8" /> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index f0b29702497..1e398d7e1aa 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -55,10 +55,10 @@ export default { <template> <div class="ide-new-btn"> <div - class="dropdown" :class="{ show: dropdownOpen, }" + class="dropdown" > <button type="button" @@ -67,19 +67,19 @@ export default { @click.stop="openDropdown()" > <icon - name="plus" :size="12" + name="plus" css-classes="float-left" /> <icon - name="arrow-down" :size="12" + name="arrow-down" css-classes="float-left" /> </button> <ul - class="dropdown-menu dropdown-menu-right" ref="dropdownMenu" + class="dropdown-menu dropdown-menu-right" > <li> <a diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index dd2800179ff..1e9668d5154 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -71,18 +71,18 @@ export default { > <form slot="body" - @submit.prevent="createEntryInStore" class="form-group row" + @submit.prevent="createEntryInStore" > <label class="label-light col-form-label col-sm-3"> {{ __('Name') }} </label> <div class="col-sm-9"> <input + ref="fieldName" + v-model="entryName" type="text" class="form-control" - v-model="entryName" - ref="fieldName" /> </div> </form> diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index c165af5ce52..677b282bd61 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -23,6 +23,7 @@ let { result } = target; if (!isText) { + // eslint-disable-next-line prefer-destructuring result = result.split('base64,')[1]; } @@ -67,9 +68,9 @@ </a> <input id="file-upload" + ref="fileUpload" type="file" class="hidden" - ref="fileUpload" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 703c4a70cfa..5cd2c9ce188 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -4,6 +4,8 @@ import tooltip from '../../../vue_shared/directives/tooltip'; import Icon from '../../../vue_shared/components/icon.vue'; import { rightSidebarViews } from '../../constants'; import PipelinesList from '../pipelines/list.vue'; +import JobsDetail from '../jobs/detail.vue'; +import ResizablePanel from '../resizable_panel.vue'; export default { directives: { @@ -12,9 +14,17 @@ export default { components: { Icon, PipelinesList, + JobsDetail, + ResizablePanel, }, computed: { ...mapState(['rightPane']), + pipelinesActive() { + return ( + this.rightPane === rightSidebarViews.pipelines || + this.rightPane === rightSidebarViews.jobsDetail + ); + }, }, methods: { ...mapActions(['setRightPane']), @@ -32,30 +42,34 @@ export default { <div class="multi-file-commit-panel ide-right-sidebar" > - <div - class="multi-file-commit-panel-inner" + <resizable-panel v-if="rightPane" + :collapsible="false" + :initial-width="350" + :min-size="350" + class="multi-file-commit-panel-inner" + side="right" > <component :is="rightPane" /> - </div> + </resizable-panel> <nav class="ide-activity-bar"> <ul class="list-unstyled"> <li> <button v-tooltip - data-container="body" - data-placement="left" :title="__('Pipelines')" - class="ide-sidebar-link is-right" :class="{ - active: rightPane === $options.rightSidebarViews.pipelines + active: pipelinesActive }" + data-container="body" + data-placement="left" + class="ide-sidebar-link is-right" type="button" @click="clickTab($event, $options.rightSidebarViews.pipelines)" > <icon :size="16" - name="pipeline" + name="rocket" /> </button> </li> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 06455fac439..5757dfdc925 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -75,8 +75,8 @@ export default { > #{{ latestPipeline.id }} <icon - name="external-link" :size="12" + name="external-link" /> </a> </span> @@ -94,7 +94,7 @@ export default { <p class="append-bottom-0"> {{ __('Found errors in your .gitlab-ci.yml:') }} </p> - <p class="append-bottom-0"> + <p class="append-bottom-0 break-word"> {{ latestPipeline.yamlError }} </p> <p diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index c5092d8e04d..50ab242ba2a 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -6,7 +6,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; import * as consts from '../stores/modules/commit/constants'; -import { activityBarViews } from '../constants'; +import { activityBarViews, stageKeys } from '../constants'; export default { components: { @@ -27,11 +27,14 @@ export default { 'unusedSeal', ]), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), - ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges']), - ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), + ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']), + ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal); }, + activeFileKey() { + return this.activeFile ? this.activeFile.key : null; + }, }, watch: { hasChanges() { @@ -44,6 +47,7 @@ export default { if (this.lastOpenedFile) { this.openPendingTab({ file: this.lastOpenedFile, + keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged, }) .then(changeViewer => { if (changeViewer) { @@ -62,6 +66,7 @@ export default { return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); }, }, + stageKeys, }; </script> @@ -72,8 +77,8 @@ export default { <deprecated-modal id="ide-create-branch-modal" :primary-button-label="__('Create new branch')" - kind="success" :title="__('Branch has changed')" + kind="success" @submit="forceCreateNewBranch" > <template slot="body"> @@ -85,22 +90,28 @@ export default { v-if="showStageUnstageArea" > <commit-files-list - class="is-first" - icon-name="unstaged" :title="__('Unstaged')" + :key-prefix="$options.stageKeys.unstaged" :file-list="changedFiles" + :action-btn-text="__('Stage all changes')" + :active-file-key="activeFileKey" action="stageAllChanges" - :action-btn-text="__('Stage all')" + action-btn-icon="mobile-issue-close" item-action-component="stage-button" + class="is-first" + icon-name="unstaged" /> <commit-files-list - icon-name="staged" :title="__('Staged')" + :key-prefix="$options.stageKeys.staged" :file-list="stagedFiles" + :action-btn-text="__('Unstage all changes')" + :staged-list="true" + :active-file-key="activeFileKey" action="unstageAllChanges" - :action-btn-text="__('Unstage all')" + action-btn-icon="history" item-action-component="unstage-button" - :staged-list="true" + icon-name="staged" /> </template> <empty-state diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 93453989c08..08ee12fd98f 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,16 +1,16 @@ <script> -/* global monaco */ import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { activityBarViews, viewerTypes } from '../constants'; -import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; import ExternalLink from './external_link.vue'; export default { components: { ContentViewer, + DiffViewer, ExternalLink, }, props: { @@ -20,7 +20,13 @@ export default { }, }, computed: { - ...mapState(['rightPanelCollapsed', 'viewer', 'panelResizing', 'currentActivityView']), + ...mapState([ + 'rightPanelCollapsed', + 'viewer', + 'panelResizing', + 'currentActivityView', + 'rightPane', + ]), ...mapGetters([ 'currentMergeRequest', 'getStagedFile', @@ -31,9 +37,18 @@ export default { shouldHideEditor() { return this.file && this.file.binary && !this.file.content; }, + showContentViewer() { + return ( + (this.shouldHideEditor || this.file.viewMode === 'preview') && + (this.viewer !== viewerTypes.mr || !this.file.mrChange) + ); + }, + showDiffViewer() { + return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr; + }, editTabCSS() { return { - active: this.file.viewMode === 'edit', + active: this.file.viewMode === 'editor', }; }, previewTabCSS() { @@ -50,12 +65,12 @@ export default { // Compare key to allow for files opened in review mode to be cached differently if (oldVal.key !== this.file.key) { - this.initMonaco(); + this.initEditor(); if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'edit', + viewMode: 'editor', }); } } @@ -64,7 +79,7 @@ export default { if (this.currentActivityView !== activityBarViews.edit) { this.setFileViewMode({ file: this.file, - viewMode: 'edit', + viewMode: 'editor', }); } }, @@ -79,20 +94,18 @@ export default { this.editor.updateDimensions(); } }, + rightPane() { + this.editor.updateDimensions(); + }, }, beforeDestroy() { this.editor.dispose(); }, mounted() { - if (this.editor && monaco) { - this.initMonaco(); - } else { - monacoLoader(['vs/editor/editor.main'], () => { - this.editor = Editor.create(monaco); - - this.initMonaco(); - }); + if (!this.editor) { + this.editor = Editor.create(); } + this.initEditor(); }, methods: { ...mapActions([ @@ -105,7 +118,7 @@ export default { 'updateViewer', 'removePendingTab', ]), - initMonaco() { + initEditor() { if (this.shouldHideEditor) return; this.editor.clearEditor(); @@ -118,7 +131,7 @@ export default { this.createEditorInstance(); }) .catch(err => { - flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true); + flash('Error setting up editor. Please try again.', 'alert', document, null, false, true); throw err; }); }, @@ -197,14 +210,14 @@ export default { > <div class="ide-mode-tabs clearfix" > <ul - class="nav-links float-left" v-if="!shouldHideEditor && isEditModeActive" + class="nav-links float-left" > <li :class="editTabCSS"> <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> + @click.prevent="setFileViewMode({ file, viewMode: 'editor' })"> <template v-if="viewer === $options.viewerTypes.edit"> {{ __('Edit') }} </template> @@ -229,19 +242,27 @@ export default { /> </div> <div - v-show="!shouldHideEditor && file.viewMode === 'edit'" + v-show="!shouldHideEditor && file.viewMode ==='editor'" ref="editor" - class="multi-file-editor-holder" :class="{ 'is-readonly': isCommitModeActive, }" + class="multi-file-editor-holder" > </div> <content-viewer - v-if="shouldHideEditor || file.viewMode === 'preview'" + v-if="showContentViewer" :content="file.content || file.raw" :path="file.rawPath || file.path" :file-size="file.size" :project-path="file.projectId"/> + <diff-viewer + v-if="showDiffViewer" + :diff-mode="file.mrChange.diffMode" + :new-path="file.mrChange.new_path" + :new-sha="currentMergeRequest.sha" + :old-path="file.mrChange.old_path" + :old-sha="currentMergeRequest.baseCommitSha" + :project-path="file.projectId"/> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index f56aeced806..f490a3a2a39 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -95,24 +95,53 @@ export default { return this.file.changed || this.file.tempFile || this.file.staged; }, }, + mounted() { + if (this.hasPathAtCurrentRoute()) { + this.scrollIntoView(true); + } + }, updated() { if (this.file.type === 'blob' && this.file.active) { - this.$el.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); + this.scrollIntoView(); } }, methods: { ...mapActions(['toggleTreeOpen']), clickFile() { // Manual Action if a tree is selected/opened - if (this.isTree && this.$router.currentRoute.path === `/project${this.file.url}`) { + if (this.isTree && this.hasUrlAtCurrentRoute()) { this.toggleTreeOpen(this.file.path); } router.push(`/project${this.file.url}`); }, + scrollIntoView(isInit = false) { + const block = isInit && this.isTree ? 'center' : 'nearest'; + + this.$el.scrollIntoView({ + behavior: 'smooth', + block, + }); + }, + hasPathAtCurrentRoute() { + if (!this.$router || !this.$router.currentRoute) { + return false; + } + + // - strip route up to "/-/" and ending "/" + const routePath = this.$router.currentRoute.path + .replace(/^.*?[/]-[/]/g, '') + .replace(/[/]$/g, ''); + + // - strip ending "/" + const filePath = this.file.path + .replace(/[/]$/g, ''); + + return filePath === routePath; + }, + hasUrlAtCurrentRoute() { + return this.$router.currentRoute.path === `/project${this.file.url}`; + }, }, }; </script> @@ -120,17 +149,17 @@ export default { <template> <div> <div - class="file" :class="fileClass" - @click="clickFile" + class="file" role="button" + @click="clickFile" > <div class="file-name" > <span - class="ide-file-name str-truncated" :style="levelIndentation" + class="ide-file-name str-truncated" > <file-icon :file-name="file.name" @@ -156,10 +185,10 @@ export default { <icon v-tooltip :title="folderChangesTooltip" + :size="12" data-container="body" data-placement="right" name="file-modified" - :size="12" css-classes="prepend-left-5 multi-file-modified" /> </span> diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index 97589e116c5..76a3333be50 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -26,8 +26,8 @@ export default { <template> <span - v-if="file.file_lock" v-tooltip + v-if="file.file_lock" :title="lockTooltip" data-container="body" > diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue index 3e47da88050..7a5ede82253 100644 --- a/app/assets/javascripts/ide/components/repo_loading_file.vue +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -33,8 +33,8 @@ <td class="d-none d-sm-block"> <skeleton-loading-container - class="animation-container-right" :small="true" + class="animation-container-right" /> </td> </template> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index fb26b973236..03772ae4a4c 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -44,6 +44,8 @@ export default { methods: { ...mapActions(['closeFile', 'updateDelayViewerUpdated', 'openPendingTab']), clickFile(tab) { + if (tab.active) return; + this.updateDelayViewerUpdated(true); if (tab.pending) { @@ -76,8 +78,8 @@ export default { @mouseout="mouseOutTab" > <div - class="multi-file-tab" :title="tab.url" + class="multi-file-tab" > <file-icon :file-name="tab.name" @@ -89,16 +91,16 @@ export default { /> </div> <button + :aria-label="closeLabel" + :disabled="tab.pending" type="button" class="multi-file-tab-close" @click.stop.prevent="closeFile(tab)" - :aria-label="closeLabel" - :disabled="tab.pending" > <icon v-if="!showChangedIcon" - name="close" :size="12" + name="close" /> <changed-file-icon v-else diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 99e51097e12..c12a63e26be 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -52,8 +52,8 @@ export default { <template> <div class="multi-file-tabs"> <ul - class="list-unstyled append-bottom-0" ref="tabsScroller" + class="list-unstyled append-bottom-0" > <repo-tab v-for="tab in files" diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue index 5ea2a2f6825..7277fcb7617 100644 --- a/app/assets/javascripts/ide/components/resizable_panel.vue +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -63,11 +63,11 @@ export default { <template> <div - class="multi-file-commit-panel" :class="{ 'is-collapsed': collapsed && collapsible, }" :style="panelStyle" + class="multi-file-commit-panel" @click="toggleFullbarCollapsed" > <slot></slot> @@ -77,9 +77,9 @@ export default { :start-size="initialWidth" :min-size="minSize" :max-size="$options.maxSize" + :side="side === 'right' ? 'left' : 'right'" @resize-start="setResizingStatus(true)" @resize-end="setResizingStatus(false)" - :side="side === 'right' ? 'left' : 'right'" /> </div> </template> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 33cd20caf52..12e0c3aeef0 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -21,6 +21,19 @@ export const viewerTypes = { diff: 'diff', }; +export const diffModes = { + replaced: 'replaced', + new: 'new', + deleted: 'deleted', + renamed: 'renamed', +}; + export const rightSidebarViews = { pipelines: 'pipelines-list', + jobsDetail: 'jobs-detail', +}; + +export const stageKeys = { + unstaged: 'unstaged', + staged: 'staged', }; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index b52618f4fde..cc8dbb942d8 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -95,14 +95,6 @@ router.beforeEach((to, from, next) => { } }) .catch(e => { - flash( - 'Error while loading the branch files. Please try again.', - 'alert', - document, - null, - false, - true, - ); throw e; }); } else if (to.params.mrid) { diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index e5149b1f3ad..78e6f632728 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -1,32 +1,32 @@ +import { editor as monacoEditor, Uri } from 'monaco-editor'; import Disposable from './disposable'; import eventHub from '../../eventhub'; export default class Model { - constructor(monaco, file, head = null) { - this.monaco = monaco; + constructor(file, head = null) { this.disposable = new Disposable(); this.file = file; this.head = head; this.content = file.content !== '' ? file.content : file.raw; this.disposable.add( - (this.originalModel = this.monaco.editor.createModel( + (this.originalModel = monacoEditor.createModel( head ? head.content : this.file.raw, undefined, - new this.monaco.Uri(null, null, `original/${this.path}`), + new Uri(false, false, `original/${this.path}`), )), - (this.model = this.monaco.editor.createModel( + (this.model = monacoEditor.createModel( this.content, undefined, - new this.monaco.Uri(null, null, this.path), + new Uri(false, false, this.path), )), ); if (this.file.mrChange) { this.disposable.add( - (this.baseModel = this.monaco.editor.createModel( + (this.baseModel = monacoEditor.createModel( this.file.baseRaw, undefined, - new this.monaco.Uri(null, null, `target/${this.path}`), + new Uri(false, false, `target/${this.path}`), )), ); } diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index 7f643969480..bd9b8fc3fcc 100644 --- a/app/assets/javascripts/ide/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -3,8 +3,7 @@ import Disposable from './disposable'; import Model from './model'; export default class ModelManager { - constructor(monaco) { - this.monaco = monaco; + constructor() { this.disposable = new Disposable(); this.models = new Map(); } @@ -22,7 +21,7 @@ export default class ModelManager { return this.getModel(file.key); } - const model = new Model(this.monaco, file, head); + const model = new Model(file, head); this.models.set(model.path, model); this.disposable.add(model); diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index f579424cf33..046e562ba2b 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -1,4 +1,4 @@ -/* global monaco */ +import { Range } from 'monaco-editor'; import { throttle } from 'underscore'; import DirtyDiffWorker from './diff_worker'; import Disposable from '../common/disposable'; @@ -16,7 +16,7 @@ export const getDiffChangeType = change => { }; export const getDecorator = change => ({ - range: new monaco.Range(change.lineNumber, 1, change.endLineNumber, 1), + range: new Range(change.lineNumber, 1, change.endLineNumber, 1), options: { isWholeLine: true, linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, diff --git a/app/assets/javascripts/ide/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js index e74c4046330..78b2eab6399 100644 --- a/app/assets/javascripts/ide/lib/diff/diff_worker.js +++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js @@ -1,8 +1,10 @@ import { computeDiff } from './diff'; +// eslint-disable-next-line no-restricted-globals self.addEventListener('message', (e) => { - const data = e.data; + const { data } = e; + // eslint-disable-next-line no-restricted-globals self.postMessage({ path: data.path, changes: computeDiff(data.originalContent, data.newContent), diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 9c3bb9cc17d..02038fcb534 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import { editor as monacoEditor, KeyCode, KeyMod } from 'monaco-editor'; import store from '../stores'; import DecorationsController from './decorations/controller'; import DirtyDiffController from './diff/controller'; @@ -8,6 +9,11 @@ import editorOptions, { defaultEditorOptions } from './editor_options'; import gitlabTheme from './themes/gl_theme'; import keymap from './keymap.json'; +function setupMonacoTheme() { + monacoEditor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme); + monacoEditor.setTheme('gitlab'); +} + export const clearDomElement = el => { if (!el || !el.firstChild) return; @@ -17,24 +23,22 @@ export const clearDomElement = el => { }; export default class Editor { - static create(monaco) { - if (this.editorInstance) return this.editorInstance; - - this.editorInstance = new Editor(monaco); - + static create() { + if (!this.editorInstance) { + this.editorInstance = new Editor(); + } return this.editorInstance; } - constructor(monaco) { - this.monaco = monaco; + constructor() { this.currentModel = null; this.instance = null; this.dirtyDiffController = null; this.disposable = new Disposable(); - this.modelManager = new ModelManager(this.monaco); + this.modelManager = new ModelManager(); this.decorationsController = new DecorationsController(this); - this.setupMonacoTheme(); + setupMonacoTheme(); this.debouncedUpdate = _.debounce(() => { this.updateDimensions(); @@ -46,7 +50,7 @@ export default class Editor { clearDomElement(domElement); this.disposable.add( - (this.instance = this.monaco.editor.create(domElement, { + (this.instance = monacoEditor.create(domElement, { ...defaultEditorOptions, })), (this.dirtyDiffController = new DirtyDiffController( @@ -66,7 +70,7 @@ export default class Editor { clearDomElement(domElement); this.disposable.add( - (this.instance = this.monaco.editor.createDiffEditor(domElement, { + (this.instance = monacoEditor.createDiffEditor(domElement, { ...defaultEditorOptions, quickSuggestions: false, occurrencesHighlight: false, @@ -122,17 +126,11 @@ export default class Editor { modified: model.getModel(), }); - this.monaco.editor.createDiffNavigator(this.instance, { + monacoEditor.createDiffNavigator(this.instance, { alwaysRevealFirst: true, }); } - setupMonacoTheme() { - this.monaco.editor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme); - - this.monaco.editor.setTheme('gitlab'); - } - clearEditor() { if (this.instance) { this.instance.setModel(null); @@ -200,7 +198,7 @@ export default class Editor { const getKeyCode = key => { const monacoKeyMod = key.indexOf('KEY_') === 0; - return monacoKeyMod ? this.monaco.KeyCode[key] : this.monaco.KeyMod[key]; + return monacoKeyMod ? KeyCode[key] : KeyMod[key]; }; keymap.forEach(command => { diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 9f895d49f2e..e35595ab1fd 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -12,5 +12,6 @@ export const defaultEditorOptions = { export default [ { readOnly: model => !!model.file.file_lock, + quickSuggestions: model => !(model.language === 'markdown'), }, ]; diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js deleted file mode 100644 index 142a220097b..00000000000 --- a/app/assets/javascripts/ide/monaco_loader.js +++ /dev/null @@ -1,16 +0,0 @@ -import monacoContext from 'monaco-editor/dev/vs/loader'; - -monacoContext.require.config({ - paths: { - vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase - }, -}); - -// ignore CDN config and use local assets path for service worker which cannot be cross-domain -const relativeRootPath = (gon && gon.relative_url_root) || ''; -const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`; -window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` }; - -// eslint-disable-next-line no-underscore-dangle -window.__monaco_context__ = monacoContext; -export default monacoContext.require; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index e8b51f2b516..3e939f0c1a3 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,15 +1,11 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; +import axios from '~/lib/utils/axios_utils'; import Api from '~/api'; -Vue.use(VueResource); - export default { - getTreeData(endpoint) { - return Vue.http.get(endpoint, { params: { format: 'json' } }); - }, getFileData(endpoint) { - return Vue.http.get(endpoint, { params: { format: 'json' } }); + return axios.get(endpoint, { + params: { format: 'json', viewer: 'none' }, + }); }, getRawFileData(file) { if (file.tempFile) { @@ -20,7 +16,11 @@ export default { return Promise.resolve(file.raw); } - return Vue.http.get(file.rawPath, { params: { format: 'json' } }).then(res => res.text()); + return axios + .get(file.rawPath, { + params: { format: 'json' }, + }) + .then(({ data }) => data); }, getBaseRawFileData(file, sha) { if (file.tempFile) { @@ -31,11 +31,11 @@ export default { return Promise.resolve(file.baseRaw); } - return Vue.http + return axios .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { params: { format: 'json' }, }) - .then(res => res.text()); + .then(({ data }) => data); }, getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); @@ -52,28 +52,12 @@ export default { getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); }, - createBranch(projectId, payload) { - const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); - - return Vue.http.post(url, payload); - }, commit(projectId, payload) { return Api.commitMultiple(projectId, payload); }, - getTreeLastCommit(endpoint) { - return Vue.http.get(endpoint, { - params: { - format: 'json', - }, - }); - }, getFiles(projectUrl, branchId) { const url = `${projectUrl}/files/${branchId}`; - return Vue.http.get(url, { - params: { - format: 'json', - }, - }); + return axios.get(url, { params: { format: 'json' } }); }, lastCommitPipelines({ getters }) { const commitSha = getters.lastCommit.id; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 3dc365eaead..5e91fa915ff 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -175,6 +175,9 @@ export const setRightPane = ({ commit }, view) => { export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links); +export const setErrorMessage = ({ commit }, errorMessage) => + commit(types.SET_ERROR_MESSAGE, errorMessage); + export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 74f9c112f5a..6c0887e11ee 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,5 +1,5 @@ -import { normalizeHeaders } from '~/lib/utils/common_utils'; -import flash from '~/flash'; +import { __ } from '../../../locale'; +import { normalizeHeaders } from '../../../lib/utils/common_utils'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; @@ -8,7 +8,7 @@ import { setPageTitle } from '../utils'; import { viewerTypes } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { - const path = file.path; + const { path } = file; const indexOfClosedFile = state.openFiles.findIndex(f => f.key === file.key); const fileWasActive = file.active; @@ -66,13 +66,10 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive .getFileData( `${gon.relative_url_root ? gon.relative_url_root : ''}${file.url.replace('/-/', '/')}`, ) - .then(res => { - const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - setPageTitle(pageTitle); + .then(({ data, headers }) => { + const normalizedHeaders = normalizeHeaders(headers); + setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE'])); - return res.json(); - }) - .then(data => { commit(types.SET_FILE_DATA, { data, file }); commit(types.TOGGLE_FILE_OPEN, path); if (makeFileActive) dispatch('setFileActive', path); @@ -80,7 +77,13 @@ export const getFileData = ({ state, commit, dispatch }, { path, makeFileActive }) .catch(() => { commit(types.TOGGLE_LOADING, { entry: file }); - flash('Error loading file data. Please try again.', 'alert', document, null, false, true); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the file.'), + action: payload => + dispatch('getFileData', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { path, makeFileActive }, + }); }); }; @@ -88,7 +91,7 @@ export const setFileMrChange = ({ commit }, { file, mrChange }) => { commit(types.SET_FILE_MERGE_REQUEST_CHANGE, { file, mrChange }); }; -export const getRawFileData = ({ state, commit }, { path, baseSha }) => { +export const getRawFileData = ({ state, commit, dispatch }, { path, baseSha }) => { const file = state.entries[path]; return new Promise((resolve, reject) => { service @@ -113,7 +116,13 @@ export const getRawFileData = ({ state, commit }, { path, baseSha }) => { } }) .catch(() => { - flash('Error loading file content. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the file content.'), + action: payload => + dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { path, baseSha }, + }); reject(); }); }); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 5ec9bd661bb..4aa151abcb7 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,29 +1,34 @@ -import flash from '~/flash'; +import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; export const getMergeRequestData = ( - { commit, state }, + { commit, dispatch, state }, { projectId, mergeRequestId, force = false } = {}, ) => new Promise((resolve, reject) => { if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { service .getProjectMergeRequestData(projectId, mergeRequestId) - .then(res => res.data) - .then(data => { + .then(({ data }) => { commit(types.SET_MERGE_REQUEST, { projectPath: projectId, mergeRequestId, mergeRequest: data, }); - if (!state.currentMergeRequestId) { - commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId); - } + commit(types.SET_CURRENT_MERGE_REQUEST, mergeRequestId); resolve(data); }) .catch(() => { - flash('Error loading merge request data. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the merge request.'), + action: payload => + dispatch('getMergeRequestData', payload).then(() => + dispatch('setErrorMessage', null), + ), + actionText: __('Please try again'), + actionPayload: { projectId, mergeRequestId, force }, + }); reject(new Error(`Merge Request not loaded ${projectId}`)); }); } else { @@ -32,15 +37,14 @@ export const getMergeRequestData = ( }); export const getMergeRequestChanges = ( - { commit, state }, + { commit, dispatch, state }, { projectId, mergeRequestId, force = false } = {}, ) => new Promise((resolve, reject) => { if (!state.projects[projectId].mergeRequests[mergeRequestId].changes.length || force) { service .getProjectMergeRequestChanges(projectId, mergeRequestId) - .then(res => res.data) - .then(data => { + .then(({ data }) => { commit(types.SET_MERGE_REQUEST_CHANGES, { projectPath: projectId, mergeRequestId, @@ -49,7 +53,15 @@ export const getMergeRequestChanges = ( resolve(data); }) .catch(() => { - flash('Error loading merge request changes. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the merge request changes.'), + action: payload => + dispatch('getMergeRequestChanges', payload).then(() => + dispatch('setErrorMessage', null), + ), + actionText: __('Please try again'), + actionPayload: { projectId, mergeRequestId, force }, + }); reject(new Error(`Merge Request Changes not loaded ${projectId}`)); }); } else { @@ -58,7 +70,7 @@ export const getMergeRequestChanges = ( }); export const getMergeRequestVersions = ( - { commit, state }, + { commit, dispatch, state }, { projectId, mergeRequestId, force = false } = {}, ) => new Promise((resolve, reject) => { @@ -75,7 +87,15 @@ export const getMergeRequestVersions = ( resolve(data); }) .catch(() => { - flash('Error loading merge request versions. Please try again.'); + dispatch('setErrorMessage', { + text: __('An error occured whilst loading the merge request version data.'), + action: payload => + dispatch('getMergeRequestVersions', payload).then(() => + dispatch('setErrorMessage', null), + ), + actionText: __('Please try again'), + actionPayload: { projectId, mergeRequestId, force }, + }); reject(new Error(`Merge Request Versions not loaded ${projectId}`)); }); } else { diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 46af47d2f81..501e25d452b 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,7 +1,10 @@ +import _ from 'underscore'; import flash from '~/flash'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import service from '../../services'; +import api from '../../../api'; import * as types from '../mutation_types'; +import router from '../../ide_router'; export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => new Promise((resolve, reject) => { @@ -13,8 +16,7 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force .then(data => { commit(types.TOGGLE_LOADING, { entry: state }); commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); - if (!state.currentProjectId) - commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); + commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); resolve(data); }) .catch(() => { @@ -33,7 +35,10 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force } }); -export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => +export const getBranchData = ( + { commit, dispatch, state }, + { projectId, branchId, force = false } = {}, +) => new Promise((resolve, reject) => { if ( typeof state.projects[`${projectId}`] === 'undefined' || @@ -52,15 +57,19 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); resolve(data); }) - .catch(() => { - flash( - __('Error loading branch data. Please try again.'), - 'alert', - document, - null, - false, - true, - ); + .catch(e => { + if (e.response.status === 404) { + dispatch('showBranchNotFoundError', branchId); + } else { + flash( + __('Error loading branch data. Please try again.'), + 'alert', + document, + null, + false, + true, + ); + } reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); }); } else { @@ -81,3 +90,37 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) .catch(() => { flash(__('Error loading last commit.'), 'alert', document, null, false, true); }); + +export const createNewBranchFromDefault = ({ state, dispatch, getters }, branch) => + api + .createBranch(state.currentProjectId, { + ref: getters.currentProject.default_branch, + branch, + }) + .then(() => { + dispatch('setErrorMessage', null); + router.push(`${router.currentRoute.path}?${Date.now()}`); + }) + .catch(() => { + dispatch('setErrorMessage', { + text: __('An error occured creating the new branch.'), + action: payload => dispatch('createNewBranchFromDefault', payload), + actionText: __('Please try again'), + actionPayload: branch, + }); + }); + +export const showBranchNotFoundError = ({ dispatch }, branchId) => { + dispatch('setErrorMessage', { + text: sprintf( + __("Branch %{branchName} was not found in this project's repository."), + { + branchName: `<strong>${_.escape(branchId)}</strong>`, + }, + false, + ), + action: payload => dispatch('createNewBranchFromDefault', payload), + actionText: __('Create branch'), + actionPayload: branchId, + }); +}; diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index cc5116413f7..ffaaaabff17 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -1,14 +1,23 @@ -import { normalizeHeaders } from '~/lib/utils/common_utils'; -import flash from '~/flash'; +import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; -import { findEntry } from '../utils'; import FilesDecoratorWorker from '../workers/files_decorator_worker'; export const toggleTreeOpen = ({ commit }, path) => { commit(types.TOGGLE_TREE_OPEN, path); }; +export const showTreeEntry = ({ commit, dispatch, state }, path) => { + const entry = state.entries[path]; + const parentPath = entry ? entry.parentPath : ''; + + if (parentPath) { + commit(types.SET_TREE_OPEN, parentPath); + + dispatch('showTreeEntry', parentPath); + } +}; + export const handleTreeEntryAction = ({ commit, dispatch }, row) => { if (row.type === 'tree') { dispatch('toggleTreeOpen', row.path); @@ -21,44 +30,23 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { } else { dispatch('getFileData', { path: row.path }); } -}; -export const getLastCommitData = ({ state, commit, dispatch }, tree = state) => { - if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; - - service - .getTreeLastCommit(tree.lastCommitPath) - .then(res => { - const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; - - commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); - - return res.json(); - }) - .then(data => { - data.forEach(lastCommit => { - const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); - - if (entry) { - commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); - } - }); - - dispatch('getLastCommitData', tree); - }) - .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); + dispatch('showTreeEntry', row.path); }; -export const getFiles = ({ state, commit }, { projectId, branchId } = {}) => +export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => new Promise((resolve, reject) => { - if (!state.trees[`${projectId}/${branchId}`]) { + if ( + !state.trees[`${projectId}/${branchId}`] || + (state.trees[`${projectId}/${branchId}`].tree && + state.trees[`${projectId}/${branchId}`].tree.length === 0) + ) { const selectedProject = state.projects[projectId]; commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service .getFiles(selectedProject.web_url, branchId) - .then(res => res.json()) - .then(data => { + .then(({ data }) => { const worker = new FilesDecoratorWorker(); worker.addEventListener('message', e => { const { entries, treeList } = e.data; @@ -86,7 +74,17 @@ export const getFiles = ({ state, commit }, { projectId, branchId } = {}) => }); }) .catch(e => { - flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); + if (e.response.status === 404) { + dispatch('showBranchNotFoundError', branchId); + } else { + dispatch('setErrorMessage', { + text: __('An error occured whilst loading all the files.'), + action: payload => + dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), + actionText: __('Please try again'), + actionPayload: { projectId, branchId }, + }); + } reject(e); }); } else { diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index b239a605371..5ce268b0d05 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -82,10 +82,13 @@ export const getStagedFilesCountForPath = state => path => getChangesCountForFiles(state.stagedFiles, path); export const lastCommit = (state, getters) => { - const branch = getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + const branch = getters.currentProject && getters.currentBranch; return branch ? branch.commit : null; }; +export const currentBranch = (state, getters) => + getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 0a0db4033c8..69b6fe2985b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -49,31 +49,6 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => { commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true }); }; -export const checkCommitStatus = ({ rootState }) => - service - .getBranchData(rootState.currentProjectId, rootState.currentBranchId) - .then(({ data }) => { - const { id } = data.commit; - const selectedBranch = - rootState.projects[rootState.currentProjectId].branches[rootState.currentBranchId]; - - if (selectedBranch.workingReference !== id) { - return true; - } - - return false; - }) - .catch(() => - flash( - __('Error checking branch data. Please try again.'), - 'alert', - document, - null, - false, - true, - ), - ); - export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data }) => { const selectedProject = rootState.projects[rootState.currentProjectId]; const lastCommit = { @@ -128,24 +103,24 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data } export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => { const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH; - const payload = createCommitPayload(getters.branchName, newBranch, state, rootState); - const getCommitStatus = newBranch ? Promise.resolve(false) : dispatch('checkCommitStatus'); + const stageFilesPromise = rootState.stagedFiles.length + ? Promise.resolve() + : dispatch('stageAllChanges', null, { root: true }); commit(types.UPDATE_LOADING, true); - return getCommitStatus - .then( - branchChanged => - new Promise(resolve => { - if (branchChanged) { - // show the modal with a Bootstrap call - $('#ide-create-branch-modal').modal('show'); - } else { - resolve(); - } - }), - ) - .then(() => service.commit(rootState.currentProjectId, payload)) + return stageFilesPromise + .then(() => { + const payload = createCommitPayload({ + branch: getters.branchName, + newBranch, + getters, + state, + rootState, + }); + + return service.commit(rootState.currentProjectId, payload); + }) .then(({ data }) => { commit(types.UPDATE_LOADING, false); @@ -220,12 +195,16 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo ); }) .catch(err => { - let errMsg = __('Error committing changes. Please try again.'); - if (err.response.data && err.response.data.message) { - errMsg += ` (${stripHtml(err.response.data.message)})`; + if (err.response.status === 400) { + $('#ide-create-branch-modal').modal('show'); + } else { + let errMsg = __('Error committing changes. Please try again.'); + if (err.response.data && err.response.data.message) { + errMsg += ` (${stripHtml(err.response.data.message)})`; + } + flash(errMsg, 'alert', document, null, false, true); + window.dispatchEvent(new Event('resize')); } - flash(errMsg, 'alert', document, null, false, true); - window.dispatchEvent(new Event('resize')); commit(types.UPDATE_LOADING, false); }); diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index d01060201f2..3db4b2f903e 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,3 +1,4 @@ +import { sprintf, n__ } from '../../../../locale'; import * as consts from './constants'; const BRANCH_SUFFIX_COUNT = 5; @@ -5,9 +6,6 @@ const BRANCH_SUFFIX_COUNT = 5; export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; -export const commitButtonDisabled = (state, getters, rootState) => - getters.discardDraftButtonDisabled || !rootState.stagedFiles.length; - export const newBranchName = (state, _, rootState) => `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr( -BRANCH_SUFFIX_COUNT, @@ -28,5 +26,18 @@ export const branchName = (state, getters, rootState) => { return rootState.currentBranchId; }; +export const preBuiltCommitMessage = (state, _, rootState) => { + if (state.commitMessage) return state.commitMessage; + + const files = (rootState.stagedFiles.length + ? rootState.stagedFiles + : rootState.changedFiles + ).reduce((acc, val) => acc.concat(val.path), []); + + return sprintf(n__('Update %{files}', 'Update %{files} files', files.length), { + files: files.join(', '), + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js index d3050183bd3..551dd322c9b 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js @@ -1,25 +1,48 @@ import { __ } from '../../../../locale'; import Api from '../../../../api'; import flash from '../../../../flash'; +import router from '../../../ide_router'; +import { scopes } from './constants'; import * as types from './mutation_types'; +import * as rootTypes from '../../mutation_types'; -export const requestMergeRequests = ({ commit }) => commit(types.REQUEST_MERGE_REQUESTS); -export const receiveMergeRequestsError = ({ commit }) => { +export const requestMergeRequests = ({ commit }, type) => + commit(types.REQUEST_MERGE_REQUESTS, type); +export const receiveMergeRequestsError = ({ commit }, type) => { flash(__('Error loading merge requests.')); - commit(types.RECEIVE_MERGE_REQUESTS_ERROR); + commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type); }; -export const receiveMergeRequestsSuccess = ({ commit }, data) => - commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data); +export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) => + commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, { type, data }); -export const fetchMergeRequests = ({ dispatch, state: { scope, state } }, search = '') => { - dispatch('requestMergeRequests'); - dispatch('resetMergeRequests'); +export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, search = '' }) => { + const scope = scopes[type]; + dispatch('requestMergeRequests', type); + dispatch('resetMergeRequests', type); Api.mergeRequests({ scope, state, search }) - .then(({ data }) => dispatch('receiveMergeRequestsSuccess', data)) - .catch(() => dispatch('receiveMergeRequestsError')); + .then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data })) + .catch(() => dispatch('receiveMergeRequestsError', type)); }; -export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS); +export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type); + +export const openMergeRequest = ({ commit, dispatch }, { projectPath, id }) => { + commit(rootTypes.CLEAR_PROJECTS, null, { root: true }); + commit(rootTypes.SET_CURRENT_MERGE_REQUEST, `${id}`, { root: true }); + commit(rootTypes.RESET_OPEN_FILES, null, { root: true }); + dispatch('setCurrentBranchId', '', { root: true }); + dispatch('pipelines/stopPipelinePolling', null, { root: true }) + .then(() => { + dispatch('pipelines/resetLatestPipeline', null, { root: true }); + dispatch('pipelines/clearEtagPoll', null, { root: true }); + }) + .catch(e => { + throw e; + }); + dispatch('setRightPane', null, { root: true }); + + router.push(`/project/${projectPath}/merge_requests/${id}`); +}; export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js b/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js index 64b7763f257..a7085c7d04c 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/constants.js @@ -1,6 +1,6 @@ export const scopes = { - assignedToMe: 'assigned-to-me', - createdByMe: 'created-by-me', + assigned: 'assigned-to-me', + created: 'created-by-me', }; export const states = { diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js b/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js new file mode 100644 index 00000000000..8e2b234be8d --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/getters.js @@ -0,0 +1,4 @@ +export const getData = state => type => state[type]; + +export const assignedData = state => state.assigned; +export const createdData = state => state.created; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/index.js b/app/assets/javascripts/ide/stores/modules/merge_requests/index.js index 04e7e0f08f1..2e6dfb420f4 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/index.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/index.js @@ -1,5 +1,6 @@ import state from './state'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; export default { @@ -7,4 +8,5 @@ export default { state: state(), actions, mutations, + getters, }; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js index 98102a68e08..971da0806bd 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js @@ -2,15 +2,15 @@ import * as types from './mutation_types'; export default { - [types.REQUEST_MERGE_REQUESTS](state) { - state.isLoading = true; + [types.REQUEST_MERGE_REQUESTS](state, type) { + state[type].isLoading = true; }, - [types.RECEIVE_MERGE_REQUESTS_ERROR](state) { - state.isLoading = false; + [types.RECEIVE_MERGE_REQUESTS_ERROR](state, type) { + state[type].isLoading = false; }, - [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, data) { - state.isLoading = false; - state.mergeRequests = data.map(mergeRequest => ({ + [types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, { type, data }) { + state[type].isLoading = false; + state[type].mergeRequests = data.map(mergeRequest => ({ id: mergeRequest.id, iid: mergeRequest.iid, title: mergeRequest.title, @@ -20,7 +20,7 @@ export default { .replace(`/merge_requests/${mergeRequest.iid}`, ''), })); }, - [types.RESET_MERGE_REQUESTS](state) { - state.mergeRequests = []; + [types.RESET_MERGE_REQUESTS](state, type) { + state[type].mergeRequests = []; }, }; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js index 2947b686c1c..57eb6b04283 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/state.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/state.js @@ -1,8 +1,13 @@ -import { scopes, states } from './constants'; +import { states } from './constants'; export default () => ({ - isLoading: false, - mergeRequests: [], - scope: scopes.assignedToMe, + created: { + isLoading: false, + mergeRequests: [], + }, + assigned: { + isLoading: false, + mergeRequests: [], + }, state: states.opened, }); diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 1ebe487263b..fe1dc9ac8f8 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -4,6 +4,7 @@ import { __ } from '../../../../locale'; import flash from '../../../../flash'; import Poll from '../../../../lib/utils/poll'; import service from '../../../services'; +import { rightSidebarViews } from '../../../constants'; import * as types from './mutation_types'; let eTagPoll; @@ -11,8 +12,12 @@ let eTagPoll; export const clearEtagPoll = () => { eTagPoll = null; }; -export const stopPipelinePolling = () => eTagPoll && eTagPoll.stop(); -export const restartPipelinePolling = () => eTagPoll && eTagPoll.restart(); +export const stopPipelinePolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; +export const restartPipelinePolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE); export const receiveLatestPipelineError = ({ commit, dispatch }) => { @@ -50,9 +55,9 @@ export const fetchLatestPipeline = ({ dispatch, rootGetters }) => { Visibility.change(() => { if (!Visibility.hidden()) { - eTagPoll.restart(); + dispatch('restartPipelinePolling'); } else { - eTagPoll.stop(); + dispatch('stopPipelinePolling'); } }); }; @@ -77,4 +82,33 @@ export const fetchJobs = ({ dispatch }, stage) => { export const toggleStageCollapsed = ({ commit }, stageId) => commit(types.TOGGLE_STAGE_COLLAPSE, stageId); +export const setDetailJob = ({ commit, dispatch }, job) => { + commit(types.SET_DETAIL_JOB, job); + dispatch('setRightPane', job ? rightSidebarViews.jobsDetail : rightSidebarViews.pipelines, { + root: true, + }); +}; + +export const requestJobTrace = ({ commit }) => commit(types.REQUEST_JOB_TRACE); +export const receiveJobTraceError = ({ commit }) => { + flash(__('Error fetching job trace')); + commit(types.RECEIVE_JOB_TRACE_ERROR); +}; +export const receiveJobTraceSuccess = ({ commit }, data) => + commit(types.RECEIVE_JOB_TRACE_SUCCESS, data); + +export const fetchJobTrace = ({ dispatch, state }) => { + dispatch('requestJobTrace'); + + return axios + .get(`${state.detailJob.path}/trace`, { params: { format: 'json' } }) + .then(({ data }) => dispatch('receiveJobTraceSuccess', data)) + .catch(() => dispatch('receiveJobTraceError')); +}; + +export const resetLatestPipeline = ({ commit }) => { + commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, null); + commit(types.SET_DETAIL_JOB, null); +}; + export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js index 3ddc8409c5b..f4c36b9d96f 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js @@ -7,3 +7,9 @@ export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR'; export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; export const TOGGLE_STAGE_COLLAPSE = 'TOGGLE_STAGE_COLLAPSE'; + +export const SET_DETAIL_JOB = 'SET_DETAIL_JOB'; + +export const REQUEST_JOB_TRACE = 'REQUEST_JOB_TRACE'; +export const RECEIVE_JOB_TRACE_ERROR = 'RECEIVE_JOB_TRACE_ERROR'; +export const RECEIVE_JOB_TRACE_SUCCESS = 'RECEIVE_JOB_TRACE_SUCCESS'; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js index 745797e1ee5..5a2213bbe89 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js @@ -63,4 +63,17 @@ export default { isCollapsed: stage.id === id ? !stage.isCollapsed : stage.isCollapsed, })); }, + [types.SET_DETAIL_JOB](state, job) { + state.detailJob = { ...job }; + }, + [types.REQUEST_JOB_TRACE](state) { + state.detailJob.isLoading = true; + }, + [types.RECEIVE_JOB_TRACE_ERROR](state) { + state.detailJob.isLoading = false; + }, + [types.RECEIVE_JOB_TRACE_SUCCESS](state, data) { + state.detailJob.isLoading = false; + state.detailJob.output = data.html; + }, }; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js index 0f83b315fff..8651e267b53 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js @@ -3,4 +3,5 @@ export default () => ({ isLoadingJobs: false, latestPipeline: null, stages: [], + detailJob: null, }); diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/utils.js b/app/assets/javascripts/ide/stores/modules/pipelines/utils.js index 9f4b0d7d726..a6caca2d2dc 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/utils.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/utils.js @@ -4,4 +4,8 @@ export const normalizeJob = job => ({ name: job.name, status: job.status, path: job.build_path, + rawPath: `${job.build_path}/raw`, + started: job.started, + output: '', + isLoading: false, }); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index fbfb92105d6..555802e1811 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -28,6 +28,7 @@ export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; // Tree mutation types export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; +export const SET_TREE_OPEN = 'SET_TREE_OPEN'; export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; export const CREATE_TREE = 'CREATE_TREE'; export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES'; @@ -68,3 +69,8 @@ export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER'; export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL'; export const SET_RIGHT_PANE = 'SET_RIGHT_PANE'; + +export const CLEAR_PROJECTS = 'CLEAR_PROJECTS'; +export const RESET_OPEN_FILES = 'RESET_OPEN_FILES'; + +export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index eeaa7cb0ec3..702be2140e2 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -157,6 +157,15 @@ export default { [types.SET_LINKS](state, links) { Object.assign(state, { links }); }, + [types.CLEAR_PROJECTS](state) { + Object.assign(state, { projects: {}, trees: {} }); + }, + [types.RESET_OPEN_FILES](state) { + Object.assign(state, { openFiles: [] }); + }, + [types.SET_ERROR_MESSAGE](state, errorMessage) { + Object.assign(state, { errorMessage }); + }, ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 13f123b6630..46547820425 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import * as types from '../mutation_types'; +import { diffModes } from '../../constants'; export default { [types.SET_FILE_ACTIVE](state, { path, active }) { @@ -46,6 +47,7 @@ export default { baseRaw: null, html: data.html, size: data.size, + lastCommitSha: data.last_commit_sha, }); }, [types.SET_FILE_RAW_DATA](state, { file, raw }) { @@ -85,8 +87,19 @@ export default { }); }, [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { + let diffMode = diffModes.replaced; + if (mrChange.new_file) { + diffMode = diffModes.new; + } else if (mrChange.deleted_file) { + diffMode = diffModes.deleted; + } else if (mrChange.renamed_file) { + diffMode = diffModes.renamed; + } Object.assign(state.entries[file.path], { - mrChange, + mrChange: { + ...mrChange, + diffMode, + }, }); }, [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index 1176c040fb9..2cf34af9274 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -6,6 +6,11 @@ export default { opened: !state.entries[path].opened, }); }, + [types.SET_TREE_OPEN](state, path) { + Object.assign(state.entries[path], { + opened: true, + }); + }, [types.CREATE_TREE](state, { treePath }) { Object.assign(state, { trees: Object.assign({}, state.trees, { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 4aac4696075..be229b2c723 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -25,4 +25,5 @@ export default () => ({ fileFindVisible: false, rightPane: null, links: {}, + errorMessage: null, }); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index e0b9766fbee..9e6b86dd844 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -17,6 +17,7 @@ export const dataStructure = () => ({ changed: false, staged: false, lastCommitPath: '', + lastCommitSha: '', lastCommit: { id: '', url: '', @@ -39,7 +40,7 @@ export const dataStructure = () => ({ editorColumn: 1, fileLanguage: '', eol: '', - viewMode: 'edit', + viewMode: 'editor', previewMode: null, size: 0, parentPath: null, @@ -104,14 +105,15 @@ export const setPageTitle = title => { document.title = title; }; -export const createCommitPayload = (branch, newBranch, state, rootState) => ({ +export const createCommitPayload = ({ branch, getters, newBranch, state, rootState }) => ({ branch, - commit_message: state.commitMessage, + commit_message: state.commitMessage || getters.preBuiltCommitMessage, actions: rootState.stagedFiles.map(f => ({ action: f.tempFile ? 'create' : 'update', file_path: f.path, content: f.content, encoding: f.base64 ? 'base64' : 'text', + last_commit_id: newBranch ? undefined : f.lastCommitSha, })), start_branch: newBranch ? rootState.currentBranchId : undefined, }); diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js index 0a1c253c637..fa35c215880 100644 --- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js +++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js @@ -1,6 +1,7 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateData, sortTree } from '../utils'; +// eslint-disable-next-line no-restricted-globals self.addEventListener('message', e => { const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data; @@ -89,6 +90,7 @@ self.addEventListener('message', e => { return acc; }, {}); + // eslint-disable-next-line no-restricted-globals self.postMessage({ entries, treeList: sortTree(treeList), diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js index 12d56714b34..a319bcccb8f 100644 --- a/app/assets/javascripts/image_diff/helpers/dom_helper.js +++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js @@ -2,7 +2,8 @@ export function setPositionDataAttribute(el, options) { // Update position data attribute so that the // new comment form can use this data for ajax request const { x, y, width, height } = options; - const position = el.dataset.position; + const { position } = el.dataset; + const positionObject = Object.assign({}, JSON.parse(position), { x, y, diff --git a/app/assets/javascripts/image_diff/helpers/utils_helper.js b/app/assets/javascripts/image_diff/helpers/utils_helper.js index 28d9a969143..beec99e6934 100644 --- a/app/assets/javascripts/image_diff/helpers/utils_helper.js +++ b/app/assets/javascripts/image_diff/helpers/utils_helper.js @@ -40,8 +40,7 @@ export function getTargetSelection(event) { const x = event.offsetX; const y = event.offsetY; - const width = imageEl.width; - const height = imageEl.height; + const { width, height } = imageEl; const actualWidth = imageEl.naturalWidth; const actualHeight = imageEl.naturalHeight; diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index b469e1e2adc..f9ff0722c01 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -58,7 +58,7 @@ class ImporterStatus { job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`); $('table.import-jobs tbody').prepend(job); - job.addClass('active'); + job.addClass('table-active'); const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing'); job.find('.import-actions').html(sprintf( _.escape(__('%{loadingIcon} Started')), { @@ -67,7 +67,15 @@ class ImporterStatus { false, )); }) - .catch(() => flash(__('An error occurred while importing project'))); + .catch((error) => { + let details = error; + + if (error.response && error.response.data && error.response.data.errors) { + details = error.response.data.errors; + } + + flash(__(`An error occurred while importing project: ${details}`)); + }); } autoUpdate() { @@ -81,7 +89,7 @@ class ImporterStatus { switch (job.import_status) { case 'finished': - jobItem.removeClass('active').addClass('success'); + jobItem.removeClass('table-active').addClass('table-success'); statusField.html(`<span><i class="fa fa-check"></i> ${__('Done')}</span>`); break; case 'scheduled': diff --git a/app/assets/javascripts/init_changes_dropdown.js b/app/assets/javascripts/init_changes_dropdown.js index 09cca1dc7d9..5c5a6e01848 100644 --- a/app/assets/javascripts/init_changes_dropdown.js +++ b/app/assets/javascripts/init_changes_dropdown.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import stickyMonitor from './lib/utils/sticky'; +import { stickyMonitor } from './lib/utils/sticky'; export default (stickyTop) => { stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop); diff --git a/app/assets/javascripts/init_notes.js b/app/assets/javascripts/init_notes.js index 882aedfcc76..3c71258e53b 100644 --- a/app/assets/javascripts/init_notes.js +++ b/app/assets/javascripts/init_notes.js @@ -7,10 +7,10 @@ export default () => { notesIds, now, diffView, - autocomplete, + enableGFM, } = JSON.parse(dataEl.innerHTML); // Create a singleton so that we don't need to assign // into the window object, we can just access the current isntance with Notes.instance - Notes.initialize(notesUrl, notesIds, now, diffView, autocomplete); + Notes.initialize(notesUrl, notesIds, now, diffView, enableGFM); }; diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index cdb75752b4e..bd90d0eaa32 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -91,7 +91,6 @@ export default class IntegrationSettingsForm { } } - /* eslint-disable promise/catch-or-return, no-new */ /** * Test Integration config */ diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js index b2c2de9e5de..07cf1eff279 100644 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -10,7 +10,7 @@ class AutoWidthDropdownSelect { } init() { - const dropdownClass = this.dropdownClass; + const { dropdownClass } = this; this.$selectElement.select2({ dropdownCssClass: dropdownClass, ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index e003fb1d127..35eaf21a836 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ +/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, prefer-arrow-callback, max-len, no-unused-vars */ import $ from 'jquery'; import _ from 'underscore'; diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index bb8b3d91e40..0140960b367 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ +/* eslint-disable no-new, no-unused-vars, consistent-return, no-else-return */ /* global GitLab */ import $ from 'jquery'; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 5113ac6775d..8c225cd7d91 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ +/* eslint-disable no-var, one-var, one-var-declaration-per-line, no-unused-vars, consistent-return, quotes, max-len */ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index e87a8ed7fea..b6364318537 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -226,7 +226,7 @@ .then(res => res.data) .then(data => this.checkForSpam(data)) .then((data) => { - if (location.pathname !== data.web_url) { + if (window.location.pathname !== data.web_url) { visitUrl(data.web_url); } diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index ae577e04a56..1174177f561 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -110,25 +110,25 @@ <template> <div v-if="descriptionHtml" - class="description" :class="{ 'js-task-list-container': canUpdate }" + class="description" > <div - class="wiki" + ref="gfm-content" :class="{ 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation }" - v-html="descriptionHtml" - ref="gfm-content"> + class="wiki" + v-html="descriptionHtml"> </div> <textarea - class="hidden js-task-list-field" v-if="descriptionText" v-model="descriptionText" :data-update-url="updateUrl" + class="hidden js-task-list-field" > </textarea> diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index 7ef5e679881..597c6d69a81 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -38,7 +38,7 @@ }, deleteIssuable() { // eslint-disable-next-line no-alert - if (confirm('Issue will be removed! Are you sure?')) { + if (window.confirm('Issue will be removed! Are you sure?')) { this.deleteLoading = true; eventHub.$emit('delete.issuable'); @@ -51,16 +51,16 @@ <template> <div class="prepend-top-default append-bottom-default clearfix"> <button - class="btn btn-save float-left" :class="{ disabled: formState.updateLoading || !isSubmitEnabled }" - type="submit" :disabled="formState.updateLoading || !isSubmitEnabled" + class="btn btn-save float-left" + type="submit" @click.prevent="updateIssuable"> Save changes <i + v-if="formState.updateLoading" class="fa fa-spinner fa-spin" - aria-hidden="true" - v-if="formState.updateLoading"> + aria-hidden="true"> </i> </button> <button @@ -71,16 +71,16 @@ </button> <button v-if="shouldShowDeleteButton" - class="btn btn-danger float-right append-right-default" :class="{ disabled: deleteLoading }" - type="button" :disabled="deleteLoading" + class="btn btn-danger float-right append-right-default" + type="button" @click="deleteIssuable"> Delete <i + v-if="deleteLoading" class="fa fa-spinner fa-spin" - aria-hidden="true" - v-if="deleteLoading"> + aria-hidden="true"> </i> </button> </div> diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue index 01097b5b35e..5ff5b1630b1 100644 --- a/app/assets/javascripts/issue_show/components/edited.vue +++ b/app/assets/javascripts/issue_show/components/edited.vue @@ -37,16 +37,16 @@ Edited <time-ago-tooltip v-if="updatedAt" - tooltip-placement="bottom" :time="updatedAt" + tooltip-placement="bottom" /> <span v-if="hasUpdatedBy" > by <a - class="author_link" :href="updatedByPath" + class="author_link" > <span>{{ updatedByName }}</span> </a> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index d9fa2764d65..5f58f671c73 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -52,13 +52,13 @@ > <textarea id="issue-description" + ref="textarea" + slot="textarea" + v-model="formState.description" class="note-textarea js-gfm-input js-autosize markdown-area" data-supports-quick-actions="false" aria-label="Description" - v-model="formState.description" - ref="textarea" - slot="textarea" - placeholder="Write a comment or drag your files here..." + placeholder="Write a comment or drag your files here…" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable"> </textarea> diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 7db0488e306..e90d9fad94e 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -48,15 +48,15 @@ class="dropdown js-issuable-selector-wrap" data-issuable-type="issue"> <button + ref="toggle" + :data-namespace-path="projectNamespace" + :data-project-path="projectPath" + :data-data="issuableTemplatesJson" class="dropdown-menu-toggle js-issuable-selector" type="button" - ref="toggle" data-field-name="issuable_template" data-selected="null" - data-toggle="dropdown" - :data-namespace-path="projectNamespace" - :data-project-path="projectPath" - :data-data="issuableTemplatesJson"> + data-toggle="dropdown"> <span class="dropdown-toggle-text"> Choose a template </span> diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue index c3abb9fd9d5..7d1526a64b4 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -21,11 +21,11 @@ </label> <input id="issuable-title" + v-model="formState.title" class="form-control" type="text" placeholder="Title" aria-label="Title" - v-model="formState.title" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" /> </fieldset> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index ab8bd34762f..5bfc072e3da 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -72,8 +72,8 @@ <locked-warning v-if="formState.lockedWarningVisible" /> <div class="row"> <div - class="col-sm-4 col-lg-3" - v-if="hasIssuableTemplates"> + v-if="hasIssuableTemplates" + class="col-sm-4 col-lg-3"> <description-template :form-state="formState" :issuable-templates="issuableTemplates" diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue index 1c2789f154a..ad0d40faf32 100644 --- a/app/assets/javascripts/issue_show/components/locked_warning.vue +++ b/app/assets/javascripts/issue_show/components/locked_warning.vue @@ -2,7 +2,7 @@ export default { computed: { currentPath() { - return location.pathname; + return window.location.pathname; }, }, }; diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index aec890a2ff6..12101c0daa5 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -67,11 +67,11 @@ <template> <div class="title-container"> <h2 - class="title" :class="{ 'issue-realtime-pre-pulse': preAnimation, 'issue-realtime-trigger-pulse': pulseAnimation }" + class="title" v-html="titleHtml" > </h2> @@ -80,11 +80,11 @@ v-if="showInlineEditButton && canUpdate" type="button" class="btn btn-default btn-edit btn-svg js-issuable-edit" - v-html="pencilIcon" title="Edit title and description" data-placement="bottom" data-container="body" @click="edit" + v-html="pencilIcon" > </button> </div> diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index 611e8200b4d..d4f2a3ef7d3 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -1,14 +1,17 @@ import $ from 'jquery'; import _ from 'underscore'; -import StickyFill from 'stickyfilljs'; +import { polyfillSticky } from './lib/utils/sticky'; import axios from './lib/utils/axios_utils'; import { visitUrl } from './lib/utils/url_utility'; import bp from './breakpoints'; import { numberToHumanSize } from './lib/utils/number_utils'; import { setCiStatusFavicon } from './lib/utils/common_utils'; +import { isScrolledToBottom, scrollDown } from './lib/utils/scroll_utils'; +import LogOutputBehaviours from './lib/utils/logoutput_behaviours'; -export default class Job { +export default class Job extends LogOutputBehaviours { constructor(options) { + super(); this.timeout = null; this.state = null; this.fetchingStatusFavicon = false; @@ -29,10 +32,6 @@ export default class Job { this.$buildTraceOutput = $('.js-build-output'); this.$topBar = $('.js-top-bar'); - // Scroll controllers - this.$scrollTopBtn = $('.js-scroll-up'); - this.$scrollBottomBtn = $('.js-scroll-down'); - clearTimeout(this.timeout); this.initSidebar(); @@ -48,23 +47,14 @@ export default class Job { .off('click', '.stage-item') .on('click', '.stage-item', this.updateDropdown); - // add event listeners to the scroll buttons - this.$scrollTopBtn - .off('click') - .on('click', this.scrollToTop.bind(this)); - - this.$scrollBottomBtn - .off('click') - .on('click', this.scrollToBottom.bind(this)); - this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100); this.$window .off('scroll') .on('scroll', () => { - if (!this.isScrolledToBottom()) { + if (!isScrolledToBottom()) { this.toggleScrollAnimation(false); - } else if (this.isScrolledToBottom() && !this.isLogComplete) { + } else if (isScrolledToBottom() && !this.isLogComplete) { this.toggleScrollAnimation(true); } this.scrollThrottled(); @@ -80,70 +70,11 @@ export default class Job { } initAffixTopArea() { - /** - If the browser does not support position sticky, it returns the position as static. - If the browser does support sticky, then we allow the browser to handle it, if not - then we use a polyfill - */ - if (this.$topBar.css('position') !== 'static') return; - - StickyFill.add(this.$topBar); - } - - // eslint-disable-next-line class-methods-use-this - canScroll() { - return $(document).height() > $(window).height(); - } - - toggleScroll() { - const $document = $(document); - const currentPosition = $document.scrollTop(); - const scrollHeight = $document.height(); - - const windowHeight = $(window).height(); - if (this.canScroll()) { - if (currentPosition > 0 && - (scrollHeight - currentPosition !== windowHeight)) { - // User is in the middle of the log - - this.toggleDisableButton(this.$scrollTopBtn, false); - this.toggleDisableButton(this.$scrollBottomBtn, false); - } else if (currentPosition === 0) { - // User is at Top of Log - - this.toggleDisableButton(this.$scrollTopBtn, true); - this.toggleDisableButton(this.$scrollBottomBtn, false); - } else if (this.isScrolledToBottom()) { - // User is at the bottom of the build log. - - this.toggleDisableButton(this.$scrollTopBtn, false); - this.toggleDisableButton(this.$scrollBottomBtn, true); - } - } else { - this.toggleDisableButton(this.$scrollTopBtn, true); - this.toggleDisableButton(this.$scrollBottomBtn, true); - } - } - // eslint-disable-next-line class-methods-use-this - isScrolledToBottom() { - const $document = $(document); - - const currentPosition = $document.scrollTop(); - const scrollHeight = $document.height(); - - const windowHeight = $(window).height(); - - return scrollHeight - currentPosition === windowHeight; - } - - // eslint-disable-next-line class-methods-use-this - scrollDown() { - const $document = $(document); - $document.scrollTop($document.height()); + polyfillSticky(this.$topBar); } scrollToBottom() { - this.scrollDown(); + scrollDown(); this.hasBeenScrolled = true; this.toggleScroll(); } @@ -154,12 +85,6 @@ export default class Job { this.toggleScroll(); } - // eslint-disable-next-line class-methods-use-this - toggleDisableButton($button, disable) { - if (disable && $button.prop('disabled')) return; - $button.prop('disabled', disable); - } - toggleScrollAnimation(toggle) { this.$scrollBottomBtn.toggleClass('animate', toggle); } @@ -191,7 +116,7 @@ export default class Job { this.state = log.state; } - this.isScrollInBottom = this.isScrolledToBottom(); + this.isScrollInBottom = isScrolledToBottom(); if (log.append) { this.$buildTraceOutput.append(log.html); @@ -231,7 +156,7 @@ export default class Job { }) .then(() => { if (this.isScrollInBottom) { - this.scrollDown(); + scrollDown(); } }) .then(() => this.toggleScroll()); @@ -239,7 +164,7 @@ export default class Job { // eslint-disable-next-line class-methods-use-this shouldHideSidebarForViewport() { const bootstrapBreakpoint = bp.getBreakpointSize(); - return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; + return bootstrapBreakpoint === 'xs'; } toggleSidebar(shouldHide) { diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue index c1044f4cd42..1e7f4b2c3f7 100644 --- a/app/assets/javascripts/jobs/components/header.vue +++ b/app/assets/javascripts/jobs/components/header.vue @@ -42,6 +42,9 @@ export default { jobStarted() { return !this.job.started === false; }, + headerTime() { + return this.jobStarted ? this.job.started : this.job.created_at; + }, }, watch: { job() { @@ -71,13 +74,13 @@ export default { <ci-header v-if="shouldRenderContent" :status="status" - item-name="Job" :item-id="job.id" - :time="job.created_at" + :time="headerTime" :user="job.user" :actions="actions" :has-sidebar-button="true" :should-render-triggered-label="jobStarted" + item-name="Job" /> <loading-icon v-if="isLoading" diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 8f3c66b0cbe..d2adf628050 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -101,8 +101,8 @@ export default { {{ __('Retry') }} </a> <button - type="button" :aria-label="__('Toggle Sidebar')" + type="button" class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle" > <i @@ -114,20 +114,20 @@ export default { </div> <template v-if="shouldRenderContent"> <div - class="block retry-link" v-if="job.retry_path || job.new_issue_path" + class="block retry-link" > <a v-if="job.new_issue_path" - class="js-new-issue btn btn-new btn-inverted" :href="job.new_issue_path" + class="js-new-issue btn btn-new btn-inverted" > {{ __('New issue') }} </a> <a v-if="canUserRetry" - class="js-retry-job btn btn-inverted-secondary" :href="job.retry_path" + class="js-retry-job btn btn-inverted-secondary" data-method="post" rel="nofollow" > @@ -136,8 +136,8 @@ export default { </div> <div :class="{block : renderBlock }"> <p - class="build-detail-row js-job-mr" v-if="job.merge_request" + class="build-detail-row js-job-mr" > <span class="build-light-text"> {{ __('Merge Request:') }} @@ -148,51 +148,51 @@ export default { </p> <detail-row - class="js-job-duration" v-if="job.duration" - title="Duration" :value="duration" + class="js-job-duration" + title="Duration" /> <detail-row - class="js-job-finished" v-if="job.finished_at" - title="Finished" :value="timeFormated(job.finished_at)" + class="js-job-finished" + title="Finished" /> <detail-row - class="js-job-erased" v-if="job.erased_at" - title="Erased" :value="timeFormated(job.erased_at)" + class="js-job-erased" + title="Erased" /> <detail-row - class="js-job-queued" v-if="job.queued" - title="Queued" :value="queued" + class="js-job-queued" + title="Queued" /> <detail-row - class="js-job-timeout" v-if="hasTimeout" - title="Timeout" :help-url="runnerHelpUrl" :value="timeout" + class="js-job-timeout" + title="Timeout" /> <detail-row - class="js-job-runner" v-if="job.runner" - title="Runner" :value="runnerId" + class="js-job-runner" + title="Runner" /> <detail-row - class="js-job-coverage" v-if="job.coverage" - title="Coverage" :value="coverage" + class="js-job-coverage" + title="Coverage" /> <p - class="build-detail-row js-job-tags" v-if="job.tags.length" + class="build-detail-row js-job-tags" > <span class="build-light-text"> {{ __('Tags:') }} @@ -210,8 +210,8 @@ export default { class="btn-group prepend-top-5" role="group"> <a - class="js-cancel-job btn btn-sm btn-default" :href="job.cancel_path" + class="js-cancel-job btn btn-sm btn-default" data-method="post" rel="nofollow" > @@ -221,8 +221,8 @@ export default { </div> </template> <loading-icon - class="prepend-top-10" v-if="isLoading" + class="prepend-top-10" size="2" /> </div> diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js index f2939ad4dbe..0db7b95636c 100644 --- a/app/assets/javascripts/jobs/job_details_bundle.js +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -4,7 +4,7 @@ import jobHeader from './components/header.vue'; import detailsBlock from './components/sidebar_details_block.vue'; export default () => { - const dataset = document.getElementById('js-job-details-vue').dataset; + const { dataset } = document.getElementById('js-job-details-vue'); const mediator = new JobMediator({ endpoint: dataset.endpoint }); mediator.fetchJob(); diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 8c3de6e4045..c10b1a2b233 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */ +/* eslint-disable class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, func-names, max-len */ import $ from 'jquery'; import Sortable from 'sortablejs'; @@ -13,6 +13,7 @@ export default class LabelManager { this.otherLabels = otherLabels || $('.js-other-labels'); this.errorMessage = 'Unable to update label prioritization at this time'; this.emptyState = document.querySelector('#js-priority-labels-empty-state'); + this.$badgeItemTemplate = $('#js-badge-item-template'); this.sortable = Sortable.create(this.prioritizedLabels.get(0), { filter: '.empty-message', forceFallback: true, @@ -63,7 +64,11 @@ export default class LabelManager { $target = this.otherLabels; $from = this.prioritizedLabels; } - $label.detach().appendTo($target); + + const $detachedLabel = $label.detach(); + this.toggleLabelPriorityBadge($detachedLabel, action); + $detachedLabel.appendTo($target); + if ($from.find('li').length) { $from.find('.empty-message').removeClass('hidden'); } @@ -88,6 +93,14 @@ export default class LabelManager { } } + toggleLabelPriorityBadge($label, action) { + if (action === 'remove') { + $('.js-priority-badge', $label).remove(); + } else { + $('.label-links', $label).append(this.$badgeItemTemplate.clone().html()); + } + } + onPrioritySortUpdate() { this.savePrioritySort() .catch(() => flash(this.errorMessage)); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index eafdaf4a672..37a45d1d1a2 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-return, func-names, space-before-function-paren, wrap-iife, no-var, no-underscore-dangle, prefer-arrow-callback, max-len, one-var, no-unused-vars, one-var-declaration-per-line, prefer-template, no-new, consistent-return, object-shorthand, comma-dangle, no-shadow, no-param-reassign, brace-style, vars-on-top, quotes, no-lonely-if, no-else-return, dot-notation, no-empty, no-return-assign, camelcase, prefer-spread */ +/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, prefer-arrow-callback, max-len, one-var, no-unused-vars, one-var-declaration-per-line, prefer-template, no-new, consistent-return, object-shorthand, comma-dangle, no-shadow, no-param-reassign, brace-style, vars-on-top, quotes, no-lonely-if, no-else-return, dot-notation, no-empty */ /* global Issuable */ /* global ListLabel */ @@ -56,7 +56,7 @@ export default class LabelsSelect { .map(function () { return this.value; }).get(); - const handleClick = options.handleClick; + const { handleClick } = options; $sidebarLabelTooltip.tooltip(); @@ -215,7 +215,7 @@ export default class LabelsSelect { } else { if (label.color != null) { - color = label.color[0]; + [color] = label.color; } } if (color) { @@ -243,7 +243,8 @@ export default class LabelsSelect { var $dropdownParent = $dropdown.parent(); var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); var isSelected = el !== null ? el.hasClass('is-active') : false; - var title = selected.title; + + var { title } = selected; var selectedLabels = this.selected; if ($dropdownInputField.length && $dropdownInputField.val().length) { @@ -382,7 +383,7 @@ export default class LabelsSelect { })); } else { - var labels = gl.issueBoards.BoardsStore.detail.issue.labels; + var { labels } = gl.issueBoards.BoardsStore.detail.issue; labels = labels.filter(function (selectedLabel) { return selectedLabel.id !== label.id; }); @@ -426,7 +427,7 @@ export default class LabelsSelect { const tpl = _.template([ '<% _.each(labels, function(label){ %>', '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">', - '<span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', + '<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', '<%- label.title %>', '</span>', '</a>', diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index dbbf1637a47..9482d131344 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -44,8 +44,8 @@ export default class LazyLoader { requestAnimationFrame(() => this.checkElementsInView()); } checkElementsInView() { - const scrollTop = pageYOffset; - const visHeight = scrollTop + innerHeight + SCROLL_THRESHOLD; + const scrollTop = window.pageYOffset; + const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD; // Loading Images which are in the current viewport or close to them this.lazyImages = this.lazyImages.filter((selectedImage) => { diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js index 3873f4528ce..c28ed04f94f 100644 --- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js +++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js @@ -93,7 +93,7 @@ export default class LinkedTabs { const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; - history.replaceState({ + window.history.replaceState({ url: newState, }, document.title, newState); return newState; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8b5445d012b..6b7550efff8 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,10 +1,14 @@ import $ from 'jquery'; -import Cookies from 'js-cookie'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase } from './text_utility'; +import { isObject } from './type_utility'; -export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index]; +export const getPagePath = (index = 0) => { + const page = $('body').attr('data-page') || ''; + + return page.split(':')[index]; +}; export const isInGroupsPage = () => getPagePath() === 'groups'; @@ -34,17 +38,18 @@ export const checkPageAndAction = (page, action) => { export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); -export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); -export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); - -export const ajaxGet = url => axios.get(url, { - params: { format: 'js' }, - responseType: 'text', -}).then(({ data }) => { - $.globalEval(data); -}); -export const rstrip = (val) => { +export const ajaxGet = url => + axios + .get(url, { + params: { format: 'js' }, + responseType: 'text', + }) + .then(({ data }) => { + $.globalEval(data); + }); + +export const rstrip = val => { if (val) { return val.replace(/\s+$/, ''); } @@ -60,7 +65,7 @@ export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventNa closestSubmit.disable(); } // eslint-disable-next-line func-names - return field.on(eventName, function () { + return field.on(eventName, function() { if (rstrip($(this).val()) === '') { return closestSubmit.disable(); } @@ -79,7 +84,7 @@ export const handleLocationHash = () => { const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`); const fixedTabs = document.querySelector('.js-tabs-affix'); - const fixedDiffStats = document.querySelector('.js-diff-files-changed.is-stuck'); + const fixedDiffStats = document.querySelector('.js-diff-files-changed'); const fixedNav = document.querySelector('.navbar-gitlab'); let adjustment = 0; @@ -102,7 +107,7 @@ export const handleLocationHash = () => { // Check if element scrolled into viewport from above or below // Courtesy http://stackoverflow.com/a/7557433/414749 -export const isInViewport = (el) => { +export const isInViewport = el => { const rect = el.getBoundingClientRect(); return ( @@ -113,13 +118,13 @@ export const isInViewport = (el) => { ); }; -export const parseUrl = (url) => { +export const parseUrl = url => { const parser = document.createElement('a'); parser.href = url; return parser; }; -export const parseUrlPathname = (url) => { +export const parseUrlPathname = url => { const parsedUrl = parseUrl(url); // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11 // We have to make sure we always have an absolute path. @@ -128,10 +133,14 @@ export const parseUrlPathname = (url) => { // We can trust that each param has one & since values containing & will be encoded // Remove the first character of search as it is always ? -export const getUrlParamsArray = () => window.location.search.slice(1).split('&').map((param) => { - const split = param.split('='); - return [decodeURI(split[0]), split[1]].join('='); -}); +export const getUrlParamsArray = () => + window.location.search + .slice(1) + .split('&') + .map(param => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); + }); export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; @@ -141,18 +150,28 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; // 3) Middle-click or Mouse Wheel Click (e.which is 2) export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; -export const scrollToElement = (element) => { +export const contentTop = () => { + const perfBar = $('#js-peek').height() || 0; + const mrTabsHeight = $('.merge-request-tabs').height() || 0; + const headerHeight = $('.navbar-gitlab').height() || 0; + const diffFilesChanged = $('.js-diff-files-changed').height() || 0; + + return perfBar + mrTabsHeight + headerHeight + diffFilesChanged; +}; + +export const scrollToElement = element => { let $el = element; if (!(element instanceof $)) { $el = $(element); } - const top = $el.offset().top; - const mrTabsHeight = $('.merge-request-tabs').height() || 0; - const headerHeight = $('.navbar-gitlab').height() || 0; + const { top } = $el.offset(); - return $('body, html').animate({ - scrollTop: top - mrTabsHeight - headerHeight, - }, 200); + return $('body, html').animate( + { + scrollTop: top - contentTop(), + }, + 200, + ); }; /** @@ -170,12 +189,25 @@ export const getParameterByName = (name, urlToParse) => { return decodeURIComponent(results[2].replace(/\+/g, ' ')); }; +const handleSelectedRange = (range) => { + const container = range.commonAncestorContainer; + // add context to fragment if needed + if (container.tagName === 'OL') { + const parentContainer = document.createElement(container.tagName); + parentContainer.appendChild(range.cloneContents()); + return parentContainer; + } + return range.cloneContents(); +}; + export const getSelectedFragment = () => { const selection = window.getSelection(); if (selection.rangeCount === 0) return null; const documentFragment = document.createDocumentFragment(); + for (let i = 0; i < selection.rangeCount; i += 1) { - documentFragment.appendChild(selection.getRangeAt(i).cloneContents()); + const range = selection.getRangeAt(i); + documentFragment.appendChild(handleSelectedRange(range)); } if (documentFragment.textContent.length === 0) return null; @@ -184,9 +216,7 @@ export const getSelectedFragment = () => { export const insertText = (target, text) => { // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas - const selectionStart = target.selectionStart; - const selectionEnd = target.selectionEnd; - const value = target.value; + const { selectionStart, selectionEnd, value } = target; const textBefore = value.substring(0, selectionStart); const textAfter = value.substring(selectionEnd, value.length); @@ -197,7 +227,10 @@ export const insertText = (target, text) => { // eslint-disable-next-line no-param-reassign target.value = newText; // eslint-disable-next-line no-param-reassign - target.selectionStart = target.selectionEnd = selectionStart + insertedText.length; + target.selectionStart = selectionStart + insertedText.length; + + // eslint-disable-next-line no-param-reassign + target.selectionEnd = selectionStart + insertedText.length; // Trigger autosave target.dispatchEvent(new Event('input')); @@ -209,7 +242,8 @@ export const insertText = (target, text) => { }; export const nodeMatchesSelector = (node, selector) => { - const matches = Element.prototype.matches || + const matches = + Element.prototype.matches || Element.prototype.matchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || @@ -222,7 +256,8 @@ export const nodeMatchesSelector = (node, selector) => { // IE11 doesn't support `node.matches(selector)` - let parentNode = node.parentNode; + let { parentNode } = node; + if (!parentNode) { parentNode = document.createElement('div'); // eslint-disable-next-line no-param-reassign @@ -238,10 +273,10 @@ export const nodeMatchesSelector = (node, selector) => { this will take in the headers from an API response and normalize them this way we don't run into production issues when nginx gives us lowercased header keys */ -export const normalizeHeaders = (headers) => { +export const normalizeHeaders = headers => { const upperCaseHeaders = {}; - Object.keys(headers || {}).forEach((e) => { + Object.keys(headers || {}).forEach(e => { upperCaseHeaders[e.toUpperCase()] = headers[e]; }); @@ -252,12 +287,14 @@ export const normalizeHeaders = (headers) => { this will take in the getAllResponseHeaders result and normalize them this way we don't run into production issues when nginx gives us lowercased header keys */ -export const normalizeCRLFHeaders = (headers) => { +export const normalizeCRLFHeaders = headers => { const headersObject = {}; const headersArray = headers.split('\n'); - headersArray.forEach((header) => { + headersArray.forEach(header => { const keyValue = header.split(': '); + + // eslint-disable-next-line prefer-destructuring headersObject[keyValue[0]] = keyValue[1]; }); @@ -292,15 +329,13 @@ export const parseIntPagination = paginationInformation => ({ export const parseQueryStringIntoObject = (query = '') => { if (query === '') return {}; - return query - .split('&') - .reduce((acc, element) => { - const val = element.split('='); - Object.assign(acc, { - [val[0]]: decodeURIComponent(val[1]), - }); - return acc; - }, {}); + return query.split('&').reduce((acc, element) => { + const val = element.split('='); + Object.assign(acc, { + [val[0]]: decodeURIComponent(val[1]), + }); + return acc; + }, {}); }; /** @@ -309,9 +344,13 @@ export const parseQueryStringIntoObject = (query = '') => { * * @param {Object} params */ -export const objectToQueryString = (params = {}) => Object.keys(params).map(param => `${param}=${params[param]}`).join('&'); +export const objectToQueryString = (params = {}) => + Object.keys(params) + .map(param => `${param}=${params[param]}`) + .join('&'); -export const buildUrlWithCurrentLocation = param => (param ? `${window.location.pathname}${param}` : window.location.pathname); +export const buildUrlWithCurrentLocation = param => + (param ? `${window.location.pathname}${param}` : window.location.pathname); /** * Based on the current location and the string parameters provided @@ -319,7 +358,7 @@ export const buildUrlWithCurrentLocation = param => (param ? `${window.location. * * @param {String} param */ -export const historyPushState = (newUrl) => { +export const historyPushState = newUrl => { window.history.pushState({}, document.title, newUrl); }; @@ -368,7 +407,7 @@ export const backOff = (fn, timeout = 60000) => { let timeElapsed = 0; return new Promise((resolve, reject) => { - const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg)); + const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); const next = () => { if (timeElapsed < timeout) { @@ -384,6 +423,49 @@ export const backOff = (fn, timeout = 60000) => { }); }; +export const createOverlayIcon = (iconPath, overlayPath) => { + const faviconImage = document.createElement('img'); + + return new Promise((resolve) => { + faviconImage.onload = () => { + const size = 32; + + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + + const context = canvas.getContext('2d'); + context.clearRect(0, 0, size, size); + context.drawImage( + faviconImage, 0, 0, faviconImage.width, faviconImage.height, 0, 0, size, size, + ); + + const overlayImage = document.createElement('img'); + overlayImage.onload = () => { + context.drawImage( + overlayImage, 0, 0, overlayImage.width, overlayImage.height, 0, 0, size, size, + ); + + const faviconWithOverlayUrl = canvas.toDataURL(); + + resolve(faviconWithOverlayUrl); + }; + overlayImage.src = overlayPath; + }; + faviconImage.src = iconPath; + }); +}; + +export const setFaviconOverlay = (overlayPath) => { + const faviconEl = document.getElementById('favicon'); + + if (!faviconEl) { return null; } + + const iconPath = faviconEl.getAttribute('data-original-href'); + + return createOverlayIcon(iconPath, overlayPath).then(faviconWithOverlayUrl => faviconEl.setAttribute('href', faviconWithOverlayUrl)); +}; + export const setFavicon = (faviconPath) => { const faviconEl = document.getElementById('favicon'); if (faviconEl && faviconPath) { @@ -393,20 +475,21 @@ export const setFavicon = (faviconPath) => { export const resetFavicon = () => { const faviconEl = document.getElementById('favicon'); - const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null; + if (faviconEl) { + const originalFavicon = faviconEl.getAttribute('data-original-href'); faviconEl.setAttribute('href', originalFavicon); } }; export const setCiStatusFavicon = pageUrl => - axios.get(pageUrl) + axios + .get(pageUrl) .then(({ data }) => { if (data && data.favicon) { - setFavicon(data.favicon); - } else { - resetFavicon(); + return setFaviconOverlay(data.favicon); } + return resetFavicon(); }) .catch(resetFavicon); @@ -423,28 +506,38 @@ export const spriteIcon = (icon, className = '') => { * Reasoning for this method is to ensure consistent property * naming conventions across JS code. */ -export const convertObjectPropsToCamelCase = (obj = {}) => { +export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { if (obj === null) { return {}; } + const initial = Array.isArray(obj) ? [] : {}; + return Object.keys(obj).reduce((acc, prop) => { const result = acc; + const val = obj[prop]; - result[convertToCamelCase(prop)] = obj[prop]; + if (options.deep && (isObject(val) || Array.isArray(val))) { + result[convertToCamelCase(prop)] = convertObjectPropsToCamelCase(val, options); + } else { + result[convertToCamelCase(prop)] = obj[prop]; + } return acc; - }, {}); + }, initial); }; -export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; +export const imagePath = imgUrl => + `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => { // Click a .js-select-on-focus field, select the contents // Prevent a mouseup event from deselecting the input $(selector).on('focusin', function selectOnFocusCallback() { - $(this).select().one('mouseup', (e) => { - e.preventDefault(); - }); + $(this) + .select() + .one('mouseup', e => { + e.preventDefault(); + }); }); }; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 0ff23bbb061..1f66fa811ea 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,11 +1,10 @@ import $ from 'jquery'; import timeago from 'timeago.js'; -import dateFormat from 'vendor/date.format'; +import dateFormat from 'dateformat'; import { pluralize } from './text_utility'; import { languageCode, s__ } from '../../locale'; window.timeago = timeago; -window.dateFormat = dateFormat; /** * Returns i18n month names array. @@ -79,37 +78,37 @@ export function getTimeago() { if (!timeagoInstance) { const localeRemaining = function getLocaleRemaining(number, index) { return [ - [s__('Timeago|less than a minute ago'), s__('Timeago|right now')], - [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')], - [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')], + [s__('Timeago|just now'), s__('Timeago|right now')], + [s__('Timeago|%s seconds ago'), s__('Timeago|%s seconds remaining')], + [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')], [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], - [s__('Timeago|about an hour ago'), s__('Timeago|1 hour remaining')], - [s__('Timeago|about %s hours ago'), s__('Timeago|%s hours remaining')], - [s__('Timeago|a day ago'), s__('Timeago|1 day remaining')], + [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')], + [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')], + [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')], [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')], - [s__('Timeago|a week ago'), s__('Timeago|1 week remaining')], + [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')], [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')], - [s__('Timeago|a month ago'), s__('Timeago|1 month remaining')], + [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')], [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')], - [s__('Timeago|a year ago'), s__('Timeago|1 year remaining')], + [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')], [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')], ][index]; }; const locale = function getLocale(number, index) { return [ - [s__('Timeago|less than a minute ago'), s__('Timeago|right now')], - [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')], - [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')], + [s__('Timeago|just now'), s__('Timeago|right now')], + [s__('Timeago|%s seconds ago'), s__('Timeago|in %s seconds')], + [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')], [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], - [s__('Timeago|about an hour ago'), s__('Timeago|in 1 hour')], - [s__('Timeago|about %s hours ago'), s__('Timeago|in %s hours')], - [s__('Timeago|a day ago'), s__('Timeago|in 1 day')], + [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')], + [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')], + [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')], [s__('Timeago|%s days ago'), s__('Timeago|in %s days')], - [s__('Timeago|a week ago'), s__('Timeago|in 1 week')], + [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')], [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')], - [s__('Timeago|a month ago'), s__('Timeago|in 1 month')], + [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')], [s__('Timeago|%s months ago'), s__('Timeago|in %s months')], - [s__('Timeago|a year ago'), s__('Timeago|in 1 year')], + [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')], [s__('Timeago|%s years ago'), s__('Timeago|in %s years')], ][index]; }; @@ -143,7 +142,8 @@ export const localTimeAgo = ($timeagoEls, setTimeago = true) => { if (setTimeago) { // Recreate with custom template $(el).tooltip({ - template: '<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>', + template: + '<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>', }); } @@ -270,6 +270,15 @@ export const totalDaysInMonth = date => { }; /** + * Returns number of days in a quarter from provided + * months array. + * + * @param {Array} quarter + */ +export const totalDaysInQuarter = quarter => + quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0); + +/** * Returns list of Dates referring to Sundays of the month * based on provided date * @@ -309,42 +318,21 @@ export const getSundays = date => { }; /** - * Returns list of Dates representing a timeframe of Months from month of provided date (inclusive) - * up to provided length - * - * For eg; - * If current month is January 2018 and `length` provided is `6` - * Then this method will return list of Date objects as follows; - * - * [ October 2017, November 2017, December 2017, January 2018, February 2018, March 2018 ] - * - * If current month is March 2018 and `length` provided is `3` - * Then this method will return list of Date objects as follows; - * - * [ February 2018, March 2018, April 2018 ] + * Returns list of Dates representing a timeframe of months from startDate and length * + * @param {Date} startDate * @param {Number} length - * @param {Date} date */ -export const getTimeframeWindow = (length, date) => { - if (!length) { +export const getTimeframeWindowFrom = (startDate, length) => { + if (!(startDate instanceof Date) || !length) { return []; } - const currentDate = date instanceof Date ? date : new Date(); - const currentMonthIndex = Math.floor(length / 2); - const timeframe = []; - - // Move date object backward to the first month of timeframe - currentDate.setDate(1); - currentDate.setMonth(currentDate.getMonth() - currentMonthIndex); - - // Iterate and update date for the size of length + // Iterate and set date for the size of length // and push date reference to timeframe list - for (let i = 0; i < length; i += 1) { - timeframe.push(new Date(currentDate.getTime())); - currentDate.setMonth(currentDate.getMonth() + 1); - } + const timeframe = new Array(length) + .fill() + .map((val, i) => new Date(startDate.getFullYear(), startDate.getMonth() + i, 1)); // Change date of last timeframe item to last date of the month timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1])); @@ -352,6 +340,30 @@ export const getTimeframeWindow = (length, date) => { return timeframe; }; +/** + * Returns count of day within current quarter from provided date + * and array of months for the quarter + * + * Eg; + * If date is 15 Feb 2018 + * and quarter is [Jan, Feb, Mar] + * + * Then 15th Feb is 46th day of the quarter + * Where 31 (days in Jan) + 15 (date of Feb). + * + * @param {Date} date + * @param {Array} quarter + */ +export const dayInQuarter = (date, quarter) => + quarter.reduce((acc, month) => { + if (date.getMonth() > month.getMonth()) { + return acc + totalDaysInMonth(month); + } else if (date.getMonth() === month.getMonth()) { + return acc + date.getDate(); + } + return acc + 0; + }, 0); + window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 914de9de940..6f42382246d 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,7 +1,4 @@ -import $ from 'jquery'; -import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie } from './common_utils'; - -const isVueMRDiscussions = () => isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); +import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; export const addClassIfElementExists = (element, className) => { if (element) { @@ -9,4 +6,4 @@ export const addClassIfElementExists = (element, className) => { } }; -export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isVueMRDiscussions(); +export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isInMRPage(); diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index bb151929431..229d53b18b0 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -8,4 +8,5 @@ export default { OK: 200, MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, + NOT_FOUND: 404, }; diff --git a/app/assets/javascripts/lib/utils/logoutput_behaviours.js b/app/assets/javascripts/lib/utils/logoutput_behaviours.js new file mode 100644 index 00000000000..1bf99d935ef --- /dev/null +++ b/app/assets/javascripts/lib/utils/logoutput_behaviours.js @@ -0,0 +1,46 @@ +import $ from 'jquery'; +import { canScroll, isScrolledToBottom, toggleDisableButton } from './scroll_utils'; + +export default class LogOutputBehaviours { + constructor() { + // Scroll buttons + this.$scrollTopBtn = $('.js-scroll-up'); + this.$scrollBottomBtn = $('.js-scroll-down'); + + this.$scrollTopBtn.off('click').on('click', this.scrollToTop.bind(this)); + this.$scrollBottomBtn.off('click').on('click', this.scrollToBottom.bind(this)); + } + + toggleScroll() { + const $document = $(document); + const currentPosition = $document.scrollTop(); + const scrollHeight = $document.height(); + + const windowHeight = $(window).height(); + if (canScroll()) { + if (currentPosition > 0 && scrollHeight - currentPosition !== windowHeight) { + // User is in the middle of the log + + toggleDisableButton(this.$scrollTopBtn, false); + toggleDisableButton(this.$scrollBottomBtn, false); + } else if (currentPosition === 0) { + // User is at Top of Log + + toggleDisableButton(this.$scrollTopBtn, true); + toggleDisableButton(this.$scrollBottomBtn, false); + } else if (isScrolledToBottom()) { + // User is at the bottom of the build log. + + toggleDisableButton(this.$scrollTopBtn, false); + toggleDisableButton(this.$scrollBottomBtn, true); + } + } else { + toggleDisableButton(this.$scrollTopBtn, true); + toggleDisableButton(this.$scrollBottomBtn, true); + } + } + + toggleScrollAnimation(toggle) { + this.$scrollBottomBtn.toggleClass('animate', toggle); + } +} diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index 973d6119158..305ad3e5e26 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */ +/* eslint-disable func-names, no-var, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, max-len */ function notificationGranted(message, opts, onclick) { var notification; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index a02c79b787e..afbab59055b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -12,8 +12,8 @@ export function formatRelevantDigits(number) { let digitsLeft = ''; let relevantDigits = 0; let formattedNumber = ''; - if (!isNaN(Number(number))) { - digitsLeft = number.toString().split('.')[0]; + if (!Number.isNaN(Number(number))) { + [digitsLeft] = number.toString().split('.'); switch (digitsLeft.length) { case 1: relevantDigits = 3; diff --git a/app/assets/javascripts/lib/utils/scroll_utils.js b/app/assets/javascripts/lib/utils/scroll_utils.js new file mode 100644 index 00000000000..9313b570863 --- /dev/null +++ b/app/assets/javascripts/lib/utils/scroll_utils.js @@ -0,0 +1,29 @@ +import $ from 'jquery'; + +export const canScroll = () => $(document).height() > $(window).height(); + +/** + * Checks if the entire page is scrolled down all the way to the bottom + */ +export const isScrolledToBottom = () => { + const $document = $(document); + + const currentPosition = $document.scrollTop(); + const scrollHeight = $document.height(); + + const windowHeight = $(window).height(); + + return scrollHeight - currentPosition === windowHeight; +}; + +export const scrollDown = () => { + const $document = $(document); + $document.scrollTop($document.height()); +}; + +export const toggleDisableButton = ($button, disable) => { + if (disable && $button.prop('disabled')) return; + $button.prop('disabled', disable); +}; + +export default {}; diff --git a/app/assets/javascripts/lib/utils/sticky.js b/app/assets/javascripts/lib/utils/sticky.js index 098afcfa1b4..15a4dd62012 100644 --- a/app/assets/javascripts/lib/utils/sticky.js +++ b/app/assets/javascripts/lib/utils/sticky.js @@ -1,3 +1,5 @@ +import StickyFill from 'stickyfilljs'; + export const createPlaceholder = () => { const placeholder = document.createElement('div'); placeholder.classList.add('sticky-placeholder'); @@ -28,7 +30,16 @@ export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => { } }; -export default (el, stickyTop, insertPlaceholder = true) => { +/** + * Create a listener that will toggle a 'is-stuck' class, based on the current scroll position. + * + * - If the current environment does not support `position: sticky`, do nothing. + * + * @param {HTMLElement} el The `position: sticky` element. + * @param {Number} stickyTop Used to determine when an element is stuck. + * @param {Boolean} insertPlaceholder Should a placeholder element be created when element is stuck? + */ +export const stickyMonitor = (el, stickyTop, insertPlaceholder = true) => { if (!el) return; if (typeof CSS === 'undefined' || !(CSS.supports('(position: -webkit-sticky) or (position: sticky)'))) return; @@ -37,3 +48,13 @@ export default (el, stickyTop, insertPlaceholder = true) => { passive: true, }); }; + +/** + * Polyfill the `position: sticky` behavior. + * + * - If the current environment supports `position: sticky`, do nothing. + * - Can receive an iterable element list (NodeList, jQuery collection, etc.) or single HTMLElement. + */ +export const polyfillSticky = (el) => { + StickyFill.add(el); +}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 5a16adea4dc..ce0bc4d40e9 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ +/* eslint-disable func-names, no-var, no-param-reassign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, max-len, consistent-return, no-unused-vars, max-len */ import $ from 'jquery'; import { insertText } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 5e786ee6935..5f25c6ce1ae 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -58,6 +58,14 @@ export const slugify = str => str.trim().toLowerCase(); export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`; /** + * Truncate SHA to 8 characters + * + * @param {String} sha + * @returns {String} + */ +export const truncateSha = sha => sha.substr(0, 8); + +/** * Capitalizes first character * * @param {String} text @@ -98,3 +106,16 @@ export const convertToSentenceCase = string => { return splitWord.join(' '); }; + +/** + * Splits camelCase or PascalCase words + * e.g. HelloWorld => Hello World + * + * @param {*} string +*/ +export const splitCamelCase = string => ( + string + .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2') + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .trim() +); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index dd17544b656..72b72f4247d 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -85,9 +85,9 @@ export function redirectTo(url) { } export function webIDEUrl(route = undefined) { - let returnUrl = `${gon.relative_url_root}/-/ide/`; + let returnUrl = `${gon.relative_url_root || ''}/-/ide/`; if (route) { - returnUrl += `project${route}`; + returnUrl += `project${route.replace(new RegExp(`^${gon.relative_url_root || ''}`), '')}`; } return returnUrl; } diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index f2323f57455..291655235d5 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-underscore-dangle, no-param-reassign, prefer-template, quotes, comma-dangle, prefer-arrow-callback, consistent-return, one-var, one-var-declaration-per-line, no-else-return, max-len */ +/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, prefer-template, quotes, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, no-else-return, max-len */ import $ from 'jquery'; @@ -35,7 +35,7 @@ const LineHighlighter = function(options = {}) { options.highlightLineClass = options.highlightLineClass || 'hll'; options.fileHolderSelector = options.fileHolderSelector || '.file-holder'; options.scrollFileHolder = options.scrollFileHolder || false; - options.hash = options.hash || location.hash; + options.hash = options.hash || window.location.hash; this.options = options; this._hash = options.hash; @@ -142,12 +142,14 @@ LineHighlighter.prototype.highlightLine = function(lineNumber) { // // range - Array containing the starting and ending line numbers LineHighlighter.prototype.highlightRange = function(range) { - var i, lineNumber, ref, ref1, results; if (range[1]) { - results = []; - for (lineNumber = i = ref = range[0], ref1 = range[1]; ref <= ref1 ? i <= ref1 : i >= ref1; lineNumber = ref <= ref1 ? (i += 1) : (i -= 1)) { + const results = []; + const ref = range[0] <= range[1] ? range : range.reverse(); + + for (let lineNumber = range[0]; lineNumber <= ref[1]; lineNumber += 1) { results.push(this.highlightLine(lineNumber)); } + return results; } else { return this.highlightLine(range[0]); @@ -170,7 +172,7 @@ LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { // // This method is stubbed in tests. LineHighlighter.prototype.__setLocationHash__ = function(value) { - return history.pushState({ + return window.history.pushState({ url: value // We're using pushState instead of assigning location.hash directly to // prevent the page from scrolling on the hashchange event diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9803bebfd10..c9ce838cd48 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -144,6 +144,7 @@ document.addEventListener('DOMContentLoaded', () => { $body.tooltip({ selector: '.has-tooltip, [data-toggle="tooltip"]', trigger: 'hover', + boundary: 'viewport', placement(tip, el) { return $(el).data('placement') || 'bottom'; }, diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js index 2cb238529aa..81950515ab4 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, quote-props, no-useless-computed-key, object-shorthand, no-new, no-param-reassign, max-len */ +/* eslint-disable comma-dangle, quote-props, no-useless-computed-key, object-shorthand, no-param-reassign, max-len */ /* global ace */ import Vue from 'vue'; @@ -11,9 +11,18 @@ import { __ } from '~/locale'; global.mergeConflicts.diffFileEditor = Vue.extend({ props: { - file: Object, - onCancelDiscardConfirmation: Function, - onAcceptDiscardConfirmation: Function + file: { + type: Object, + required: true, + }, + onCancelDiscardConfirmation: { + type: Function, + required: true, + }, + onAcceptDiscardConfirmation: { + type: Function, + required: true, + }, }, data() { return { diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js index 56d6678e1bd..827cf5f478d 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js @@ -1,14 +1,19 @@ -/* eslint-disable no-param-reassign, comma-dangle */ +/* eslint-disable no-param-reassign */ import Vue from 'vue'; +import actionsMixin from '../mixins/line_conflict_actions'; +import utilsMixin from '../mixins/line_conflict_utils'; -((global) => { +(global => { global.mergeConflicts = global.mergeConflicts || {}; global.mergeConflicts.inlineConflictLines = Vue.extend({ + mixins: [utilsMixin, actionsMixin], props: { - file: Object + file: { + type: Object, + required: true, + }, }, - mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions], }); })(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js index 0fc4a13450a..69208ac2d36 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js @@ -1,15 +1,20 @@ -/* eslint-disable no-param-reassign, comma-dangle */ +/* eslint-disable no-param-reassign */ import Vue from 'vue'; +import actionsMixin from '../mixins/line_conflict_actions'; +import utilsMixin from '../mixins/line_conflict_utils'; ((global) => { global.mergeConflicts = global.mergeConflicts || {}; global.mergeConflicts.parallelConflictLines = Vue.extend({ + mixins: [utilsMixin, actionsMixin], props: { - file: Object + file: { + type: Object, + required: true, + }, }, - mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions], template: ` <table> <tr class="line_holder parallel" v-for="section in file.parallelLines"> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js index c68b47c9348..64d69159222 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js @@ -1,23 +1,16 @@ -/* eslint-disable no-param-reassign, comma-dangle */ import axios from '../lib/utils/axios_utils'; -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - class mergeConflictsService { - constructor(options) { - this.conflictsPath = options.conflictsPath; - this.resolveConflictsPath = options.resolveConflictsPath; - } - - fetchConflictsData() { - return axios.get(this.conflictsPath); - } +export default class MergeConflictsService { + constructor(options) { + this.conflictsPath = options.conflictsPath; + this.resolveConflictsPath = options.resolveConflictsPath; + } - submitResolveConflicts(data) { - return axios.post(this.resolveConflictsPath, data); - } + fetchConflictsData() { + return axios.get(this.conflictsPath); } - global.mergeConflicts.mergeConflictsService = mergeConflictsService; -})(window.gl || (window.gl = {})); + submitResolveConflicts(data) { + return axios.post(this.resolveConflictsPath, data); + } +} diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js index 70f185e3656..1501296ac4f 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js @@ -156,7 +156,7 @@ import Cookies from 'js-cookie'; return 0; } - const files = this.state.conflictsData.files; + const { files } = this.state.conflictsData; let count = 0; files.forEach((file) => { @@ -313,7 +313,7 @@ import Cookies from 'js-cookie'; }, isReadyToCommit() { - const files = this.state.conflictsData.files; + const { files } = this.state.conflictsData; const hasCommitMessage = $.trim(this.state.conflictsData.commitMessage).length; let unresolved = 0; diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index 4abd5433bb5..7badd68089c 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,13 +1,9 @@ -/* eslint-disable new-cap, comma-dangle, no-new */ - import $ from 'jquery'; import Vue from 'vue'; -import Flash from '../flash'; +import createFlash from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; import './merge_conflict_store'; -import './merge_conflict_service'; -import './mixins/line_conflict_utils'; -import './mixins/line_conflict_actions'; +import MergeConflictsService from './merge_conflict_service'; import './components/diff_file_editor'; import './components/inline_conflict_lines'; import './components/parallel_conflict_lines'; @@ -16,10 +12,10 @@ import syntaxHighlight from '../syntax_highlight'; export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; const conflictsEl = document.querySelector('#conflicts'); - const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore; - const mergeConflictsService = new gl.mergeConflicts.mergeConflictsService({ + const { mergeConflictsStore } = gl.mergeConflicts; + const mergeConflictsService = new MergeConflictsService({ conflictsPath: conflictsEl.dataset.conflictsPath, - resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath + resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath, }); initIssuableSidebar(); @@ -29,17 +25,26 @@ export default function initMergeConflicts() { components: { 'diff-file-editor': gl.mergeConflicts.diffFileEditor, 'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines, - 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines + 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines, }, data: mergeConflictsStore.state, computed: { - conflictsCountText() { return mergeConflictsStore.getConflictsCountText(); }, - readyToCommit() { return mergeConflictsStore.isReadyToCommit(); }, - commitButtonText() { return mergeConflictsStore.getCommitButtonText(); }, - showDiffViewTypeSwitcher() { return mergeConflictsStore.fileTextTypePresent(); } + conflictsCountText() { + return mergeConflictsStore.getConflictsCountText(); + }, + readyToCommit() { + return mergeConflictsStore.isReadyToCommit(); + }, + commitButtonText() { + return mergeConflictsStore.getCommitButtonText(); + }, + showDiffViewTypeSwitcher() { + return mergeConflictsStore.fileTextTypePresent(); + }, }, created() { - mergeConflictsService.fetchConflictsData() + mergeConflictsService + .fetchConflictsData() .then(({ data }) => { if (data.type === 'error') { mergeConflictsStore.setFailedRequest(data.message); @@ -87,9 +92,9 @@ export default function initMergeConflicts() { }) .catch(() => { mergeConflictsStore.setSubmitState(false); - new Flash('Failed to save merge conflicts resolutions. Please try again!'); + createFlash('Failed to save merge conflicts resolutions. Please try again!'); }); - } - } + }, + }, }); } diff --git a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js b/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js index 53e000d7e9e..364ae2b2688 100644 --- a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js +++ b/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js @@ -1,13 +1,7 @@ -/* eslint-disable no-param-reassign, comma-dangle */ - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.actions = { - methods: { - handleSelected(file, sectionId, selection) { - gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection); - } - } - }; -})(window.gl || (window.gl = {})); +export default { + methods: { + handleSelected(file, sectionId, selection) { + gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection); + }, + }, +}; diff --git a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_utils.js b/app/assets/javascripts/merge_conflicts/mixins/line_conflict_utils.js index 0f475f62ee6..d25032fb142 100644 --- a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_utils.js +++ b/app/assets/javascripts/merge_conflicts/mixins/line_conflict_utils.js @@ -1,19 +1,13 @@ -/* eslint-disable no-param-reassign, quote-props, comma-dangle */ - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.utils = { - methods: { - lineCssClass(line) { - return { - 'head': line.isHead, - 'origin': line.isOrigin, - 'match': line.hasMatch, - 'selected': line.isSelected, - 'unselected': line.isUnselected - }; - } - } - }; -})(window.gl || (window.gl = {})); +export default { + methods: { + lineCssClass(line) { + return { + head: line.isHead, + origin: line.isOrigin, + match: line.hasMatch, + selected: line.isSelected, + unselected: line.isUnselected, + }; + }, + }, +}; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index d8222ebec63..7bf2c56dd5d 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */ +/* eslint-disable func-names, no-var, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, comma-dangle, max-len, prefer-arrow-callback */ import $ from 'jquery'; import { __ } from '~/locale'; @@ -49,6 +49,7 @@ MergeRequest.prototype.initTabs = function() { if (window.mrTabs) { window.mrTabs.unbindEvents(); } + window.mrTabs = new MergeRequestTabs(this.opts); }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 493c119dc6f..53d7504de35 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,6 +1,7 @@ /* eslint-disable no-new, class-methods-use-this */ import $ from 'jquery'; +import Vue from 'vue'; import Cookies from 'js-cookie'; import axios from './lib/utils/axios_utils'; import flash from './flash'; @@ -8,12 +9,14 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import initChangesDropdown from './init_changes_dropdown'; import bp from './breakpoints'; import { parseUrlPathname, handleLocationHash, isMetaClick } from './lib/utils/common_utils'; +import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { getLocationHash } from './lib/utils/url_utility'; import initDiscussionTab from './image_diff/init_discussion_tab'; import Diff from './diff'; import { localTimeAgo } from './lib/utils/datetime_utility'; import syntaxHighlight from './syntax_highlight'; import Notes from './notes'; +import { polyfillSticky } from './lib/utils/sticky'; /* eslint-disable max-len */ // MergeRequestTabs @@ -62,32 +65,45 @@ import Notes from './notes'; /* eslint-enable max-len */ // Store the `location` object, allowing for easier stubbing in tests -let location = window.location; +let { location } = window; export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { - const mergeRequestTabs = document.querySelector('.js-tabs-affix'); + this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container'); + this.mergeRequestTabsAll = + this.mergeRequestTabs && this.mergeRequestTabs.querySelectorAll + ? this.mergeRequestTabs.querySelectorAll('.merge-request-tabs li') + : null; + this.mergeRequestTabPanes = document.querySelector('#diff-notes-app'); + this.mergeRequestTabPanesAll = + this.mergeRequestTabPanes && this.mergeRequestTabPanes.querySelectorAll + ? this.mergeRequestTabPanes.querySelectorAll('.tab-pane') + : null; const navbar = document.querySelector('.navbar-gitlab'); const peek = document.getElementById('js-peek'); const paddingTop = 16; + this.commitsTab = document.querySelector('.tab-content .commits.tab-pane'); + + this.currentTab = null; this.diffsLoaded = false; this.pipelinesLoaded = false; this.commitsLoaded = false; this.fixedLayoutPref = null; + this.eventHub = new Vue(); this.setUrl = setUrl !== undefined ? setUrl : true; this.setCurrentAction = this.setCurrentAction.bind(this); this.tabShown = this.tabShown.bind(this); - this.showTab = this.showTab.bind(this); + this.clickTab = this.clickTab.bind(this); this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0; if (peek) { this.stickyTop += peek.offsetHeight; } - if (mergeRequestTabs) { - this.stickyTop += mergeRequestTabs.offsetHeight; + if (this.mergeRequestTabs) { + this.stickyTop += this.mergeRequestTabs.offsetHeight; } if (stubLocation) { @@ -95,25 +111,22 @@ export default class MergeRequestTabs { } this.bindEvents(); - this.activateTab(action); + if ( + this.mergeRequestTabs && + this.mergeRequestTabs.querySelector(`a[data-action='${action}']`) && + this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click + ) + this.mergeRequestTabs.querySelector(`a[data-action='${action}']`).click(); this.initAffix(); } bindEvents() { - $(document) - .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) - .on('click', '.js-show-tab', this.showTab); - - $('.merge-request-tabs a[data-toggle="tab"]').on('click', this.clickTab); + $('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab); } // Used in tests unbindEvents() { - $(document) - .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) - .off('click', '.js-show-tab', this.showTab); - - $('.merge-request-tabs a[data-toggle="tab"]').off('click', this.clickTab); + $('.merge-request-tabs a[data-toggle="tabvue"]').off('click', this.clickTab); } destroyPipelinesView() { @@ -125,52 +138,86 @@ export default class MergeRequestTabs { } } - showTab(e) { - e.preventDefault(); - this.activateTab($(e.target).data('action')); - } - clickTab(e) { - if (e.currentTarget && isMetaClick(e)) { - const targetLink = e.currentTarget.getAttribute('href'); + if (e.currentTarget) { e.stopImmediatePropagation(); e.preventDefault(); - window.open(targetLink, '_blank'); + + const { action } = e.currentTarget.dataset; + + if (action) { + const href = e.currentTarget.getAttribute('href'); + this.tabShown(action, href); + } else if (isMetaClick(e)) { + const targetLink = e.currentTarget.getAttribute('href'); + window.open(targetLink, '_blank'); + } } } - tabShown(e) { - const $target = $(e.target); - const action = $target.data('action'); - - if (action === 'commits') { - this.loadCommits($target.attr('href')); - this.expandView(); - this.resetViewContainer(); - this.destroyPipelinesView(); - } else if (this.isDiffAction(action)) { - this.loadDiff($target.attr('href')); - if (bp.getBreakpointSize() !== 'lg') { - this.shrinkView(); + tabShown(action, href) { + if (action !== this.currentTab && this.mergeRequestTabs) { + this.currentTab = action; + + if (this.mergeRequestTabPanesAll) { + this.mergeRequestTabPanesAll.forEach(el => { + const tabPane = el; + tabPane.style.display = 'none'; + }); } - if (this.diffViewType() === 'parallel') { - this.expandViewContainer(); + + if (this.mergeRequestTabsAll) { + this.mergeRequestTabsAll.forEach(el => { + el.classList.remove('active'); + }); } - this.destroyPipelinesView(); - } else if (action === 'pipelines') { - this.resetViewContainer(); - this.mountPipelinesView(); - } else { - if (bp.getBreakpointSize() !== 'xs') { + + const tabPane = this.mergeRequestTabPanes.querySelector(`#${action}`); + if (tabPane) tabPane.style.display = 'block'; + const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`); + if (tab) tab.classList.add('active'); + + if (action === 'commits') { + this.loadCommits(href); + this.expandView(); + this.resetViewContainer(); + this.destroyPipelinesView(); + } else if (action === 'new') { this.expandView(); + this.resetViewContainer(); + this.destroyPipelinesView(); + } else if (this.isDiffAction(action)) { + if (!isInVueNoteablePage()) { + this.loadDiff(href); + } + if (bp.getBreakpointSize() !== 'lg') { + this.shrinkView(); + } + if (this.diffViewType() === 'parallel') { + this.expandViewContainer(); + } + this.destroyPipelinesView(); + this.commitsTab.classList.remove('active'); + } else if (action === 'pipelines') { + this.resetViewContainer(); + this.mountPipelinesView(); + } else { + this.mergeRequestTabPanes.querySelector('#notes').style.display = 'block'; + this.mergeRequestTabs.querySelector('.notes-tab').classList.add('active'); + + if (bp.getBreakpointSize() !== 'xs') { + this.expandView(); + } + this.resetViewContainer(); + this.destroyPipelinesView(); + + initDiscussionTab(); + } + if (this.setUrl) { + this.setCurrentAction(action); } - this.resetViewContainer(); - this.destroyPipelinesView(); - initDiscussionTab(); - } - if (this.setUrl) { - this.setCurrentAction(action); + this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); } } @@ -184,12 +231,6 @@ export default class MergeRequestTabs { } } - // Activate a tab based on the current action - activateTab(action) { - // important note: the .tab('show') method triggers 'shown.bs.tab' event itself - $(`.merge-request-tabs a[data-action='${action}']`).tab('show'); - } - // Replaces the current Merge Request-specific action in the URL with a new one // // If the action is "notes", the URL is reset to the standard @@ -270,7 +311,7 @@ export default class MergeRequestTabs { mountPipelinesView() { const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); - const CommitPipelinesTable = gl.CommitPipelinesTable; + const { CommitPipelinesTable } = gl; this.commitPipelinesTable = new CommitPipelinesTable({ propsData: { endpoint: pipelineTableViewEl.dataset.endpoint, @@ -362,7 +403,7 @@ export default class MergeRequestTabs { // // status - Boolean, true to show, false to hide toggleLoading(status) { - $('.mr-loading-status .loading').toggleClass('hidden', !status); + $('.mr-loading-status .loading').toggleClass('hide', !status); } diffViewType() { @@ -417,7 +458,6 @@ export default class MergeRequestTabs { initAffix() { const $tabs = $('.js-tabs-affix'); - const $fixedNav = $('.navbar-gitlab'); // Screen space on small screens is usually very sparse // So we dont affix the tabs on these @@ -430,21 +470,6 @@ export default class MergeRequestTabs { */ if ($tabs.css('position') !== 'static') return; - const $diffTabs = $('#diff-notes-app'); - - $tabs - .off('affix.bs.affix affix-top.bs.affix') - .affix({ - offset: { - top: () => $diffTabs.offset().top - $tabs.height() - $fixedNav.height(), - }, - }) - .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() })) - .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' })); - - // Fix bug when reloading the page already scrolling - if ($tabs.hasClass('affix')) { - $tabs.trigger('affix.bs.affix'); - } + polyfillSticky($tabs); } } diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 325fa570f37..6da04020881 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -18,13 +18,13 @@ export default class Milestone { return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { const $target = $(e.target); - location.hash = $target.attr('href'); + window.location.hash = $target.attr('href'); this.loadTab($target); }); } // eslint-disable-next-line class-methods-use-this loadInitialTab() { - const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`); + const $target = $(`.js-milestone-tabs a[href="${window.location.hash}"]`); if ($target.length) { $target.tab('show'); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index f8b3d3061f0..77acba6e355 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-underscore-dangle, prefer-arrow-callback, max-len, one-var, one-var-declaration-per-line, no-unused-vars, object-shorthand, comma-dangle, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */ +/* eslint-disable max-len, one-var, one-var-declaration-per-line, no-unused-vars, object-shorthand, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */ /* global Issuable */ /* global ListMilestone */ @@ -16,10 +16,10 @@ export default class MilestoneSelect { typeof currentProject === 'string' ? JSON.parse(currentProject) : currentProject; } - this.init(els, options); + MilestoneSelect.init(els, options); } - init(els, options) { + static init(els, options) { let $els = $(els); if (!els) { @@ -56,7 +56,7 @@ export default class MilestoneSelect { if (issueUpdateURL) { milestoneLinkTemplate = _.template( - '<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', + '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', ); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; } @@ -224,7 +224,6 @@ export default class MilestoneSelect { $selectBox.hide(); $value.css('display', ''); if (data.milestone != null) { - data.milestone.full_path = this.currentProject.full_path; data.milestone.remaining = timeFor(data.milestone.due_date); data.milestone.name = data.milestone.title; $value.html(milestoneLinkTemplate(data.milestone)); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index f5572be5fbf..e1c8b6a6d4a 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -139,7 +139,7 @@ export default { this.updateAspectRatio = true; }, toggleAspectRatio() { - this.updatedAspectRatios = this.updatedAspectRatios += 1; + this.updatedAspectRatios += 1; if (this.store.getMetricsCount() === this.updatedAspectRatios) { this.updateAspectRatio = !this.updateAspectRatio; this.updatedAspectRatios = 0; @@ -174,7 +174,10 @@ export default { :tags-path="tagsPath" :show-legend="showLegend" :small-graph="forceSmallGraph" - /> + > + <!-- EE content --> + {{ null }} + </graph> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index c77f451c2d3..82b9a4b1adb 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -107,8 +107,8 @@ export default { <div class="state-button"> <a v-if="currentState.buttonPath" - class="btn btn-success" :href="currentState.buttonPath" + class="btn btn-success" > {{ currentState.buttonText }} </a> @@ -116,8 +116,8 @@ export default { <div class="state-button"> <a v-if="currentState.secondaryButtonPath" - class="btn" :href="currentState.secondaryButtonPath" + class="btn" > {{ currentState.secondaryButtonText }} </a> diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index de6755e0414..e5680a0499f 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -154,7 +154,7 @@ export default { point.x = e.clientX; point.y = e.clientY; point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); - point.x = point.x += 7; + point.x += 7; const firstTimeSeries = this.timeSeries[0]; const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); @@ -232,20 +232,25 @@ export default { @mouseover="showFlagContent = true" @mouseleave="showFlagContent = false" > - <h5 class="text-center graph-title"> - {{ graphData.title }} - </h5> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title"> + {{ graphData.title }} + </h5> + <div class="prometheus-graph-widgets"> + <slot></slot> + </div> + </div> <div - class="prometheus-svg-container" :style="paddingBottomRootSvg" + class="prometheus-svg-container" > <svg - :viewBox="outerViewBox" ref="baseSvg" + :viewBox="outerViewBox" > <g - class="x-axis" :transform="axisTransform" + class="x-axis" /> <g class="y-axis" @@ -260,9 +265,9 @@ export default { :unit-of-display="unitOfDisplay" /> <svg - class="graph-data" - :viewBox="innerViewBox" ref="graphData" + :viewBox="innerViewBox" + class="graph-data" > <graph-path v-for="(path, index) in timeSeries" @@ -282,11 +287,11 @@ export default { :graph-height-offset="graphHeightOffset" /> <rect - class="prometheus-graph-overlay" + ref="graphOverlay" :width="(graphWidth - 70)" :height="(graphHeight - 100)" + class="prometheus-graph-overlay" transform="translate(-5, 20)" - ref="graphOverlay" @mousemove="handleMouseOverGraph($event)" /> </svg> diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue index fc4b3689dfd..8a604a51eb2 100644 --- a/app/assets/javascripts/monitoring/components/graph/axis.vue +++ b/app/assets/javascripts/monitoring/components/graph/axis.vue @@ -92,48 +92,48 @@ export default { <template> <g class="axis-label-container"> <line + :y1="yPosition" + :x2="graphWidth + 20" + :y2="yPosition" class="label-x-axis-line" stroke="#000000" stroke-width="1" x1="10" - :y1="yPosition" - :x2="graphWidth + 20" - :y2="yPosition" /> <line + :x2="10" + :y2="yPosition" class="label-y-axis-line" stroke="#000000" stroke-width="1" x1="10" y1="0" - :x2="10" - :y2="yPosition" /> <rect - class="rect-axis-text" :transform="rectTransform" :width="yLabelWidth" :height="yLabelHeight" + class="rect-axis-text" /> <text + ref="ylabel" + :transform="textTransform" class="label-axis-text y-label-text" text-anchor="middle" - :transform="textTransform" - ref="ylabel" > {{ yAxisLabelSentenceCase }} </text> <rect - class="rect-axis-text" :x="xPosition + 60" :y="graphHeight - 80" + class="rect-axis-text" width="35" height="50" /> <text - class="label-axis-text x-label-text" :x="xPosition + 60" :y="yPosition" + class="label-axis-text x-label-text" dy=".35em" > {{ timeString }} diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue index 4012191ceb9..a7289ed53e8 100644 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue @@ -33,18 +33,18 @@ export default { :key="index" :transform="transformDeploymentGroup(deployment)"> <rect + :height="calculatedHeight" x="0" y="0" - :height="calculatedHeight" width="3" fill="url(#shadow-gradient)" /> <line + :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" - :y2="calculatedHeight" stroke="#000" /> </g> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index 8a771107de8..92fe98508ad 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -97,7 +97,7 @@ export default { ? this.deploymentFlagData.seriesIndex : indexFromCoordinates; const value = series.values[index] && series.values[index].value; - if (isNaN(value)) { + if (Number.isNaN(value)) { return '-'; } return `${formatRelevantDigits(value)}${this.unitOfDisplay}`; @@ -117,13 +117,13 @@ export default { <template> <div - class="prometheus-graph-cursor" :style="cursorStyle" + class="prometheus-graph-cursor" > <div v-if="showFlagContent" - class="prometheus-graph-flag popover" :class="flagOrientation" + class="prometheus-graph-flag popover" > <div class="arrow"></div> <div class="popover-title"> @@ -139,8 +139,8 @@ export default { > <div> <icon - name="commit" :size="12" + name="commit" /> <a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} @@ -150,8 +150,8 @@ export default { v-if="deploymentFlagData.tag" > <icon - name="label" :size="12" + name="label" /> <a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index da9280cf1f1..3276f3a1ceb 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -31,8 +31,8 @@ export default { <table class="prometheus-table"> <tr v-for="(series, index) in timeSeries" - :key="index" v-if="series.shouldRenderLegend" + :key="index" :class="isStable(series)" > <td> @@ -40,11 +40,11 @@ export default { </td> <track-line :track="series" /> <td - class="legend-metric-title" - v-if="timeSeries.length > 1"> + v-if="timeSeries.length > 1" + class="legend-metric-title"> <track-info - :track="series" - v-if="series.metricTag" /> + v-if="series.metricTag" + :track="series" /> <track-info v-else :track="series"> @@ -62,8 +62,8 @@ export default { :key="`track-line-${trackIndex}`"/> <td :key="`track-info-${trackIndex}`"> <track-info - class="legend-metric-title" - :track="track" /> + :track="track" + class="legend-metric-title" /> </td> </template> </tr> diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue index 52f8aa2ee3f..a9b7ce586ce 100644 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ b/app/assets/javascripts/monitoring/components/graph/path.vue @@ -44,26 +44,26 @@ export default { <template> <g transform="translate(-5, 20)"> <circle - class="circle-path" + v-if="showDot" :cx="currentCoordinates.currentX" :cy="currentCoordinates.currentY" :fill="lineColor" :stroke="lineColor" + class="circle-path" r="3" - v-if="showDot" /> <path - class="metric-area" :d="generatedAreaPath" :fill="areaColor" + class="metric-area" /> <path - class="metric-line" :d="generatedLinePath" :stroke="lineColor" + :stroke-dasharray="strokeDashArray" + class="metric-line" fill="none" stroke-width="1" - :stroke-dasharray="strokeDashArray" /> </g> </template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue index 18be65fd1ef..ba3f93b39ff 100644 --- a/app/assets/javascripts/monitoring/components/graph/track_line.vue +++ b/app/assets/javascripts/monitoring/components/graph/track_line.vue @@ -24,11 +24,11 @@ export default { <line :stroke-dasharray="stylizedLine" :stroke="track.lineColor" - stroke-width="4" :x1="0" :x2="16" :y1="4" :y2="4" + stroke-width="4" /> </svg> </td> diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index 4d3f1f1a7cc..cee39fd0559 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -41,10 +41,10 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom } else { const unusedColors = _.difference(defaultColorOrder, usedColors); if (unusedColors.length > 0) { - pick = unusedColors[0]; + [pick] = unusedColors; } else { usedColors = []; - pick = defaultColorOrder[0]; + [pick] = defaultColorOrder; } } usedColors.push(pick); @@ -73,7 +73,7 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom timeSeriesScaleX.ticks(d3.timeMinute, 60); timeSeriesScaleY.domain(yDom); - const defined = d => !isNaN(d.value) && d.value != null; + const defined = d => !Number.isNaN(d.value) && d.value != null; const lineFunction = d3 .line() diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index e3c5bf06b3d..8aabb840847 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,20 +1,32 @@ +import $ from 'jquery'; import Vue from 'vue'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import initDiffsApp from '../diffs'; import notesApp from '../notes/components/notes_app.vue'; import discussionCounter from '../notes/components/discussion_counter.vue'; -import store from '../notes/stores'; +import store from './stores'; +import MergeRequest from '../merge_request'; export default function initMrNotes() { + const mrShowNode = document.querySelector('.merge-request'); + // eslint-disable-next-line no-new + new MergeRequest({ + action: mrShowNode.dataset.mrAction, + }); + // eslint-disable-next-line no-new new Vue({ el: '#js-vue-mr-discussions', + name: 'MergeRequestDiscussions', components: { notesApp, }, + store, data() { - const notesDataset = document.getElementById('js-vue-mr-discussions') - .dataset; + const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; const noteableData = JSON.parse(notesDataset.noteableData); noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; return { noteableData, @@ -22,12 +34,42 @@ export default function initMrNotes() { notesData: JSON.parse(notesDataset.notesData), }; }, + computed: { + ...mapGetters(['discussionTabCounter']), + ...mapState({ + activeTab: state => state.page.activeTab, + }), + }, + watch: { + discussionTabCounter() { + this.updateDiscussionTabCounter(); + }, + }, + created() { + this.setActiveTab(window.mrTabs.getCurrentAction()); + }, + mounted() { + this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); + $(document).on('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); + }, + beforeDestroy() { + $(document).off('visibilitychange', this.updateDiscussionTabCounter); + window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); + }, + methods: { + ...mapActions(['setActiveTab']), + updateDiscussionTabCounter() { + this.notesCountBadge.text(this.discussionTabCounter); + }, + }, render(createElement) { return createElement('notes-app', { props: { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, + shouldShow: this.activeTab === 'show', }, }); }, @@ -36,6 +78,7 @@ export default function initMrNotes() { // eslint-disable-next-line no-new new Vue({ el: '#js-vue-discussion-counter', + name: 'DiscussionCounter', components: { discussionCounter, }, @@ -44,4 +87,6 @@ export default function initMrNotes() { return createElement('discussion-counter'); }, }); + + initDiffsApp(store); } diff --git a/app/assets/javascripts/mr_notes/stores/actions.js b/app/assets/javascripts/mr_notes/stores/actions.js new file mode 100644 index 00000000000..426c6a00d5e --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/actions.js @@ -0,0 +1,7 @@ +import types from './mutation_types'; + +export default { + setActiveTab({ commit }, tab) { + commit(types.SET_ACTIVE_TAB, tab); + }, +}; diff --git a/app/assets/javascripts/mr_notes/stores/getters.js b/app/assets/javascripts/mr_notes/stores/getters.js new file mode 100644 index 00000000000..b10e9f9f9f1 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/getters.js @@ -0,0 +1,5 @@ +export default { + isLoggedIn(state, getters) { + return !!getters.getUserData.id; + }, +}; diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js new file mode 100644 index 00000000000..dd2019001db --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import notesModule from '~/notes/stores/modules'; +import diffsModule from '~/diffs/store/modules'; +import mrPageModule from './modules'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + modules: { + page: mrPageModule, + notes: notesModule, + diffs: diffsModule, + }, +}); diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js new file mode 100644 index 00000000000..660081f76c8 --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/modules/index.js @@ -0,0 +1,12 @@ +import actions from '../actions'; +import getters from '../getters'; +import mutations from '../mutations'; + +export default { + state: { + activeTab: null, + }, + actions, + getters, + mutations, +}; diff --git a/app/assets/javascripts/mr_notes/stores/mutation_types.js b/app/assets/javascripts/mr_notes/stores/mutation_types.js new file mode 100644 index 00000000000..105104361cf --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/mutation_types.js @@ -0,0 +1,3 @@ +export default { + SET_ACTIVE_TAB: 'SET_ACTIVE_TAB', +}; diff --git a/app/assets/javascripts/mr_notes/stores/mutations.js b/app/assets/javascripts/mr_notes/stores/mutations.js new file mode 100644 index 00000000000..8175aa9488f --- /dev/null +++ b/app/assets/javascripts/mr_notes/stores/mutations.js @@ -0,0 +1,7 @@ +import types from './mutation_types'; + +export default { + [types.SET_ACTIVE_TAB](state, tab) { + Object.assign(state, { activeTab: tab }); + }, +}; diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index c7a8aac79df..17370edeb0c 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, max-len */ +/* eslint-disable func-names, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, max-len */ import $ from 'jquery'; import Api from './api'; diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index bd007c707f2..94da1be4066 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, quotes, comma-dangle, one-var, one-var-declaration-per-line, no-mixed-operators, no-loop-func, no-floating-decimal, consistent-return, no-unused-vars, prefer-template, prefer-arrow-callback, camelcase, max-len */ +/* eslint-disable func-names, no-var, wrap-iife, quotes, comma-dangle, one-var, one-var-declaration-per-line, no-loop-func, no-floating-decimal, consistent-return, no-unused-vars, prefer-template, prefer-arrow-callback, camelcase, max-len */ import $ from 'jquery'; import { __ } from '../locale'; @@ -101,8 +101,8 @@ export default (function() { }; BranchGraph.prototype.buildGraph = function() { - var cuday, cumonth, day, j, len, mm, r, ref; - r = this.r; + var cuday, cumonth, day, j, len, mm, ref; + const { r } = this; cuday = 0; cumonth = ""; r.rect(0, 0, 40, this.barHeight).attr({ @@ -112,7 +112,8 @@ export default (function() { fill: "#444" }); ref = this.days; - for (mm = j = 0, len = ref.length; j < len; mm = (j += 1)) { + + for (mm = 0, len = ref.length; mm < len; mm += 1) { day = ref[mm]; if (cuday !== day[0] || cumonth !== day[1]) { // Dates @@ -120,7 +121,7 @@ export default (function() { font: "12px Monaco, monospace", fill: "#BBB" }); - cuday = day[0]; + [cuday] = day; } if (cumonth !== day[1]) { // Months @@ -128,6 +129,8 @@ export default (function() { font: "12px Monaco, monospace", fill: "#EEE" }); + + // eslint-disable-next-line prefer-destructuring cumonth = day[1]; } } @@ -168,8 +171,8 @@ export default (function() { }; BranchGraph.prototype.bindEvents = function() { - var element; - element = this.element; + const { element } = this; + return $(element).scroll((function(_this) { return function(event) { return _this.renderPartialGraph(); @@ -206,11 +209,13 @@ export default (function() { }; BranchGraph.prototype.appendLabel = function(x, y, commit) { - var label, r, rect, shortrefs, text, textbox, triangle; + var label, rect, shortrefs, text, textbox, triangle; + if (!commit.refs) { return; } - r = this.r; + + const { r } = this; shortrefs = commit.refs; // Truncate if longer than 15 chars if (shortrefs.length > 17) { @@ -241,11 +246,8 @@ export default (function() { }; BranchGraph.prototype.appendAnchor = function(x, y, commit) { - var anchor, options, r, top; - r = this.r; - top = this.top; - options = this.options; - anchor = r.circle(x, y, 10).attr({ + const { r, top, options } = this; + const anchor = r.circle(x, y, 10).attr({ fill: "#000", opacity: 0, cursor: "pointer" @@ -261,14 +263,15 @@ export default (function() { }; BranchGraph.prototype.drawDot = function(x, y, commit) { - var avatar_box_x, avatar_box_y, r; - r = this.r; + const { r } = this; r.circle(x, y, 3).attr({ fill: this.colors[commit.space], stroke: "none" }); - avatar_box_x = this.offsetX + this.unitSpace * this.mspace + 10; - avatar_box_y = y - 10; + + const avatar_box_x = this.offsetX + this.unitSpace * this.mspace + 10; + const avatar_box_y = y - 10; + r.rect(avatar_box_x, avatar_box_y, 20, 20).attr({ stroke: this.colors[commit.space], "stroke-width": 2 @@ -281,11 +284,12 @@ export default (function() { }; BranchGraph.prototype.drawLines = function(x, y, commit) { - var arrow, color, i, j, len, offset, parent, parentCommit, parentX1, parentX2, parentY, r, ref, results, route; - r = this.r; - ref = commit.parents; - results = []; - for (i = j = 0, len = ref.length; j < len; i = (j += 1)) { + var arrow, color, i, len, offset, parent, parentCommit, parentX1, parentX2, parentY, route; + const { r } = this; + const ref = commit.parents; + const results = []; + + for (i = 0, len = ref.length; i < len; i += 1) { parent = ref[i]; parentCommit = this.preparedCommits[parent[0]]; parentY = this.offsetY + this.unitTime * parentCommit.time; @@ -329,11 +333,10 @@ export default (function() { }; BranchGraph.prototype.markCommit = function(commit) { - var r, x, y; if (commit.id === this.options.commit_id) { - r = this.r; - x = this.offsetX + this.unitSpace * (this.mspace - commit.space); - y = this.offsetY + this.unitTime * commit.time; + const { r } = this; + const x = this.offsetX + this.unitSpace * (this.mspace - commit.space); + const y = this.offsetY + this.unitTime * commit.time; r.path(["M", x + 5, y, "L", x + 15, y + 4, "L", x + 15, y - 4, "Z"]).attr({ fill: "#000", "fill-opacity": .5, diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 40c08ee0ace..205d9766656 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */ +/* eslint-disable func-names, no-var, one-var, max-len, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; @@ -52,7 +52,7 @@ export default class NewBranchForm { validate() { var errorMessage, errors, formatter, unique, validator; - const indexOf = [].indexOf; + const { indexOf } = []; this.branchNameError.empty(); unique = function(values, value) { diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index a2f0a44863f..17ec20f1cc1 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */ +/* eslint-disable no-var, no-return-assign */ export default class NewCommitForm { constructor(form) { this.form = form; diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue index b4067d229aa..18cef82cec0 100644 --- a/app/assets/javascripts/notebook/cells/code.vue +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -39,10 +39,10 @@ export default { <template> <div class="cell"> <code-cell - type="input" :raw-code="rawInputCode" :count="cell.execution_count" - :code-css-class="codeCssClass" /> + :code-css-class="codeCssClass" + type="input" /> <output-cell v-if="hasOutput" :count="cell.execution_count" diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index 0f3083f05b2..7d2a1a33b98 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -48,9 +48,9 @@ :type="promptType" :count="count" /> <pre - class="language-python" - :class="codeCssClass" ref="code" + :class="codeCssClass" + class="language-python" v-text="code"> </pre> </div> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index 91b2269a83a..4183b976814 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -78,10 +78,10 @@ <template> <component :is="componentName" - type="output" :output-type="outputType" :count="count" :raw-code="rawCode" :code-css-class="codeCssClass" + type="output" /> </template> diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b2c1a26bbae..48cda28a1ae 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,10 +1,8 @@ -/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, -no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, -no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, -default-case, prefer-template, consistent-return, no-alert, no-return-assign, -no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, -brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, -newline-per-chained-call, no-useless-escape, class-methods-use-this */ +/* eslint-disable no-restricted-properties, func-names, no-var, wrap-iife, camelcase, +no-unused-expressions, max-len, one-var, one-var-declaration-per-line, default-case, +prefer-template, consistent-return, no-alert, no-return-assign, +no-param-reassign, prefer-arrow-callback, no-else-return, vars-on-top, +no-unused-vars, no-shadow, no-useless-escape, class-methods-use-this */ /* global ResolveService */ /* global mrRefreshWidgetUrl */ @@ -22,6 +20,7 @@ import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_c import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; import Flash from './flash'; +import { defaultAutocompleteConfig } from './gfm_auto_complete'; import CommentTypeToggle from './comment_type_toggle'; import GLForm from './gl_form'; import loadAwardsHandler from './awards_handler'; @@ -32,7 +31,7 @@ import { getPagePath, scrollToElement, isMetaKey, - hasVueMRDiscussionsCookie, + isInMRPage, } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -47,21 +46,9 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; export default class Notes { - static initialize( - notes_url, - note_ids, - last_fetched_at, - view, - enableGFM = true, - ) { + static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM) { if (!this.instance) { - this.instance = new Notes( - notes_url, - note_ids, - last_fetched_at, - view, - enableGFM, - ); + this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM); } } @@ -69,7 +56,7 @@ export default class Notes { return this.instance; } - constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { + constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = defaultAutocompleteConfig) { this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateComment = this.updateComment.bind(this); this.visibilityChange = this.visibilityChange.bind(this); @@ -104,13 +91,11 @@ export default class Notes { this.basePollingInterval = 15000; this.maxPollingSteps = 4; - this.$wrapperEl = hasVueMRDiscussionsCookie() - ? $(document).find('.diffs') - : $(document); + this.$wrapperEl = isInMRPage() ? $(document).find('.diffs') : $(document); this.cleanBinding(); this.addBinding(); this.setPollingInterval(); - this.setupMainTargetNoteForm(); + this.setupMainTargetNoteForm(enableGFM); this.taskList = new TaskList({ dataType: 'note', fieldName: 'note', @@ -146,55 +131,27 @@ export default class Notes { // Reopen and close actions for Issue/MR combined with note form submit this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); - this.$wrapperEl.on( - 'keyup input', - '.js-note-text', - this.updateTargetButtons, - ); + this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons); // resolve a discussion this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment - this.$wrapperEl.on( - 'click', - '.js-note-attachment-delete', - this.removeAttachment, - ); + this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); // reset main target form when clicking discard this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - this.$wrapperEl.on( - 'change', - '.js-note-attachment-input', - this.updateFormAttachment, - ); + this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes - this.$wrapperEl.on( - 'click', - '.js-discussion-reply-button', - this.onReplyToDiscussionNote, - ); + this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images - this.$wrapperEl.on( - 'click', - '.js-add-image-diff-note-button', - this.onAddImageDiffNote, - ); + this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); // hide diff note form - this.$wrapperEl.on( - 'click', - '.js-close-discussion-note-form', - this.cancelDiscussionForm, - ); + this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list - this.$wrapperEl.on( - 'click', - '.system-note-commit-list-toggler', - this.toggleCommitList, - ); + this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); this.$wrapperEl.on('click', '.js-toggle-lazy-diff', this.loadLazyDiff); this.$wrapperEl.on('click', '.js-toggle-lazy-diff-retry-button', this.onClickRetryLazyLoad.bind(this)); @@ -205,16 +162,8 @@ export default class Notes { this.$wrapperEl.on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote); - this.$wrapperEl.on( - 'ajax:success', - '.js-discussion-note-form', - this.addDiscussionNote, - ); - this.$wrapperEl.on( - 'ajax:success', - '.js-main-target-form', - this.resetMainTargetForm, - ); + this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); + this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); this.$wrapperEl.on( 'ajax:complete', '.js-main-target-form', @@ -224,8 +173,6 @@ export default class Notes { this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); // When the URL fragment/hash has changed, `#note_xxx` $(window).on('hashchange', this.onHashChange); - this.boundGetContent = this.getContent.bind(this); - document.addEventListener('refreshLegacyNotes', this.boundGetContent); } cleanBinding() { @@ -249,21 +196,14 @@ export default class Notes { this.$wrapperEl.off('ajax:success', '.js-main-target-form'); this.$wrapperEl.off('ajax:success', '.js-discussion-note-form'); this.$wrapperEl.off('ajax:complete', '.js-main-target-form'); - document.removeEventListener('refreshLegacyNotes', this.boundGetContent); $(window).off('hashchange', this.onHashChange); } static initCommentTypeToggle(form) { - const dropdownTrigger = form.querySelector( - '.js-comment-type-dropdown .dropdown-toggle', - ); - const dropdownList = form.querySelector( - '.js-comment-type-dropdown .dropdown-menu', - ); + const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); + const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); const noteTypeInput = form.querySelector('#note_type'); - const submitButton = form.querySelector( - '.js-comment-type-dropdown .js-comment-submit-button', - ); + const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); const closeButton = form.querySelector('.js-note-target-close'); const reopenButton = form.querySelector('.js-note-target-reopen'); @@ -299,9 +239,7 @@ export default class Notes { return; } myLastNote = $( - `li.note[data-author-id='${ - gon.current_user_id - }'][data-editable]:last`, + `li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, .notes_holder, #notes'), ); if (myLastNote.length) { @@ -315,7 +253,7 @@ export default class Notes { if (discussionNoteForm.length) { if ($textarea.val() !== '') { if ( - !confirm('Are you sure you want to cancel creating this comment?') + !window.confirm('Are you sure you want to cancel creating this comment?') ) { return; } @@ -329,7 +267,7 @@ export default class Notes { newText = $textarea.val(); if (originalText !== newText) { if ( - !confirm('Are you sure you want to cancel editing this comment?') + !window.confirm('Are you sure you want to cancel editing this comment?') ) { return; } @@ -373,7 +311,7 @@ export default class Notes { }, }) .then(({ data }) => { - const notes = data.notes; + const { notes } = data; this.last_fetched_at = data.last_fetched_at; this.setPollingInterval(data.notes.length); $.each(notes, (i, note) => this.renderNote(note)); @@ -398,8 +336,7 @@ export default class Notes { if (shouldReset == null) { shouldReset = true; } - nthInterval = - this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); if (shouldReset) { this.pollingInterval = this.basePollingInterval; } else if (this.pollingInterval < nthInterval) { @@ -420,10 +357,7 @@ export default class Notes { loadAwardsHandler() .then(awardsHandler => { - awardsHandler.addAwardToEmojiBar( - votesBlock, - noteEntity.commands_changes.emoji_award, - ); + awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); awardsHandler.scrollToAwards(); }) .catch(() => { @@ -473,17 +407,10 @@ export default class Notes { if (!noteEntity.valid) { if (noteEntity.errors && noteEntity.errors.commands_only) { - if ( - noteEntity.commands_changes && - Object.keys(noteEntity.commands_changes).length > 0 - ) { + if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); } - this.addFlash( - noteEntity.errors.commands_only, - 'notice', - this.parentTimeline.get(0), - ); + this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0)); this.refresh(); } return; @@ -491,7 +418,7 @@ export default class Notes { const $note = $notesList.find(`#note_${noteEntity.id}`); if (Notes.isNewNote(noteEntity, this.note_ids)) { - if (hasVueMRDiscussionsCookie()) { + if (isInMRPage()) { return; } @@ -519,8 +446,7 @@ export default class Notes { // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way const sanitizedNoteNote = normalizeNewlines(noteEntity.note); const isTextareaUntouched = - currentContent === initialContent || - currentContent === sanitizedNoteNote; + currentContent === initialContent || currentContent === sanitizedNoteNote; if (isEditing && isTextareaUntouched) { $textarea.val(noteEntity.note); @@ -533,8 +459,6 @@ export default class Notes { this.setupNewNote($updatedNote); } } - - Notes.refreshVueNotes(); } isParallelView() { @@ -552,13 +476,7 @@ export default class Notes { } this.note_ids.push(noteEntity.id); - form = - $form || - $( - `.js-discussion-note-form[data-discussion-id="${ - noteEntity.discussion_id - }"]`, - ); + form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); row = form.length || !noteEntity.discussion_line_code ? form.closest('tr') @@ -574,9 +492,7 @@ export default class Notes { .first() .find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? - discussionContainer = $( - `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, - ); + discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } @@ -584,18 +500,12 @@ export default class Notes { if (noteEntity.diff_discussion_html) { var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); - if ( - !this.isParallelView() || - row.hasClass('js-temp-notes-holder') || - noteEntity.on_image - ) { + if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) { // insert the note and the reply button after the temp row row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find( - `.notes[data-discussion-id="${noteEntity.discussion_id}"]`, - ); + var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); var contentContainerClass = '.' + $notes @@ -608,29 +518,15 @@ export default class Notes { .find(contentContainerClass + ' .content') .append($notes.closest('.content').children()); } - } - // Init discussion on 'Discussion' page if it is merge request page - const page = $('body').attr('data-page'); - if ( - (page && page.indexOf('projects:merge_request') !== -1) || - !noteEntity.diff_discussion_html - ) { - if (!hasVueMRDiscussionsCookie()) { - Notes.animateAppendNote( - noteEntity.discussion_html, - $('.main-notes-list'), - ); - } + } else { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { // append new note to all matching discussions Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if ( - typeof gl.diffNotesCompileComponents !== 'undefined' && - noteEntity.discussion_resolvable - ) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { gl.diffNotesCompileComponents(); this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); @@ -703,14 +599,14 @@ export default class Notes { * * Sets some hidden fields in the form. */ - setupMainTargetNoteForm() { + setupMainTargetNoteForm(enableGFM) { var form; // find the form form = $('.js-new-note-form'); // Set a global clone of the form for later cloning this.formClone = form.clone(); // show the form - this.setupNoteForm(form); + this.setupNoteForm(form, enableGFM); // fix classes form.removeClass('js-new-note-form'); form.addClass('js-main-target-form'); @@ -738,9 +634,9 @@ export default class Notes { * setup GFM auto complete * show the form */ - setupNoteForm(form) { + setupNoteForm(form, enableGFM = defaultAutocompleteConfig) { var textarea, key; - this.glForm = new GLForm(form, this.enableGFM); + this.glForm = new GLForm(form, enableGFM); textarea = form.find('.js-note-text'); key = [ 'Note', @@ -784,6 +680,7 @@ export default class Notes { } updateNoteError($parentTimeline) { + // eslint-disable-next-line no-new new Flash( 'Your comment could not be updated! Please check your network connection and try again.', ); @@ -939,9 +836,7 @@ export default class Notes { form.removeClass('current-note-edit-form'); form.find('.js-finish-edit-warning').hide(); // Replace markdown textarea text with original note text. - return form - .find('.js-note-text') - .val(form.find('form.edit-note').data('originalNote')); + return form.find('.js-note-text').val(form.find('form.edit-note').data('originalNote')); } /** @@ -989,21 +884,15 @@ export default class Notes { // The notes tr can contain multiple lists of notes, like on the parallel diff // notesTr does not exist for image diffs - if ( - notesTr.find('.discussion-notes').length > 1 || - notesTr.length === 0 - ) { + if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { const $diffFile = $notes.closest('.diff-file'); if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent( - 'removeBadge.imageDiff', - { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, + const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, }, - ); + }); $diffFile[0].dispatchEvent(removeBadgeEvent); } @@ -1017,7 +906,6 @@ export default class Notes { })(this), ); - Notes.refreshVueNotes(); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); } @@ -1033,7 +921,7 @@ export default class Notes { $note.find('.note-attachment').remove(); $note.find('.note-body > .note-text').show(); $note.find('.note-header').show(); - return $note.find('.current-note-edit-form').remove(); + return $note.find('.diffs .current-note-edit-form').remove(); } /** @@ -1107,9 +995,7 @@ export default class Notes { form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); - form - .removeClass('js-main-target-form') - .addClass('discussion-form js-discussion-note-form'); + form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { var $commentBtn = form.find('comment-and-resolve-btn'); @@ -1119,9 +1005,7 @@ export default class Notes { } form.find('.js-note-text').focus(); - form - .find('.js-comment-resolve-button') - .attr('data-discussion-id', discussionID); + form.find('.js-comment-resolve-button').attr('data-discussion-id', discussionID); } /** @@ -1154,9 +1038,7 @@ export default class Notes { // Setup comment form let newForm; - const $noteContainer = $link - .closest('.diff-viewer') - .find('.note-container'); + const $noteContainer = $link.closest('.diff-viewer').find('.note-container'); const $form = $noteContainer.find('> .discussion-form'); if ($form.length === 0) { @@ -1225,9 +1107,7 @@ export default class Notes { notesContent = targetRow.find(notesContentSelector); addForm = true; } else { - const isCurrentlyShown = targetRow - .find('.content:not(:empty)') - .is(':visible'); + const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible'); const isForced = forceShow === true || forceShow === false; const showNow = forceShow === true || (!isCurrentlyShown && !isForced); @@ -1392,9 +1272,7 @@ export default class Notes { if ($note.find('.js-conflict-edit-warning').length === 0) { const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> This comment has changed since you started editing, please review the - <a href="#note_${ - noteEntity.id - }" target="_blank" rel="noopener noreferrer"> + <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> updated comment </a> to ensure information is not lost @@ -1404,15 +1282,13 @@ export default class Notes { } updateNotesCount(updateCount) { - return this.notesCountBadge.text( - parseInt(this.notesCountBadge.text(), 10) + updateCount, - ); + return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); } static renderPlaceholderComponent($container) { const el = $container.find('.js-code-placeholder').get(0); + // eslint-disable-next-line no-new new Vue({ - // eslint-disable-line no-new el, components: { SkeletonLoadingContainer, @@ -1483,9 +1359,7 @@ export default class Notes { toggleCommitList(e) { const $element = $(e.currentTarget); - const $closestSystemCommitList = $element.siblings( - '.system-note-commit-list', - ); + const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); $element .find('.fa') @@ -1518,9 +1392,7 @@ export default class Notes { $systemNote.find('.note-text').addClass('system-note-commit-list'); $systemNote.find('.system-note-commit-list-toggler').show(); } else { - $systemNote - .find('.note-text') - .addClass('system-note-commit-list hide-shade'); + $systemNote.find('.note-text').addClass('system-note-commit-list hide-shade'); } }); } @@ -1591,10 +1463,6 @@ export default class Notes { return $updatedNote; } - static refreshVueNotes() { - document.dispatchEvent(new CustomEvent('refreshVueNotes')); - } - /** * Get data from Form attributes to use for saving/submitting comment. */ @@ -1675,7 +1543,7 @@ export default class Notes { <div class="note-header"> <div class="note-header-info"> <a href="/${_.escape(currentUsername)}"> - <span class="d-none d-sm-block">${_.escape( + <span class="d-none d-sm-inline-block">${_.escape( currentUsername, )}</span> <span class="note-headline-light">${_.escape( @@ -1694,7 +1562,7 @@ export default class Notes { </li>`, ); - $tempNote.find('.d-none.d-sm-block').text(_.escape(currentUserFullname)); + $tempNote.find('.d-none.d-sm-inline-block').text(_.escape(currentUserFullname)); $tempNote .find('.note-headline-light') .text(`@${_.escape(currentUsername)}`); @@ -1753,15 +1621,8 @@ export default class Notes { .attr('id') === 'discussion'; const isMainForm = $form.hasClass('js-main-target-form'); const isDiscussionForm = $form.hasClass('js-discussion-note-form'); - const isDiscussionResolve = $submitBtn.hasClass( - 'js-comment-resolve-button', - ); - const { - formData, - formContent, - formAction, - formContentOriginal, - } = this.getFormData($form); + const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); + const { formData, formContent, formAction, formContentOriginal } = this.getFormData($form); let noteUniqueId; let systemNoteUniqueId; let hasQuickActions = false; @@ -1827,7 +1688,6 @@ export default class Notes { $closeBtn.text($closeBtn.data('originalText')); - /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server return axios .post(`${formAction}?html=true`, formData) @@ -1849,9 +1709,7 @@ export default class Notes { // Reset cached commands list when command is applied if (hasQuickActions) { - $form - .find('textarea.js-note-text') - .trigger('clear-commands-cache.atwho'); + $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); } // Clear previous form errors @@ -1896,12 +1754,8 @@ export default class Notes { // append flash-container to the Notes list if ($notesContainer.length) { - $notesContainer.append( - '<div class="flash-container" style="display: none;"></div>', - ); + $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); } - - Notes.refreshVueNotes(); } else if (isMainForm) { // Check if this was main thread comment // Show final note element on UI and perform form and action buttons cleanup @@ -1935,9 +1789,7 @@ export default class Notes { // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { - const replyButton = $notesContainer - .parent() - .find('.js-discussion-reply-button'); + const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); this.replyToDiscussionNote(replyButton[0]); $form = $notesContainer.parent().find('form'); } @@ -1980,16 +1832,13 @@ export default class Notes { // Show updated comment content temporarily $noteBodyText.html(formContent); - $editingNote - .removeClass('is-editing fade-in-full') - .addClass('being-posted fade-in-half'); + $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); $editingNote .find('.note-headline-meta a') .html( '<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>', ); - /* eslint-disable promise/catch-or-return */ // Make request to update comment on server axios .post(`${formAction}?html=true`, formData) diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 72d7e22fba0..c6a524f68cb 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,10 +7,7 @@ import { __, sprintf } from '~/locale'; import Flash from '../../flash'; import Autosave from '../../autosave'; import TaskList from '../../task_list'; -import { - capitalizeFirstCharacter, - convertToCamelCase, -} from '../../lib/utils/text_utility'; +import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase } from '../../lib/utils/text_utility'; import * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -56,21 +53,23 @@ export default { ]), ...mapState(['isToggleStateButtonLoading']), noteableDisplayName() { - return this.noteableType.replace(/_/g, ' '); + return splitCamelCase(this.noteableType).toLowerCase(); }, isLoggedIn() { return this.getUserData.id; }, commentButtonTitle() { - return this.noteType === constants.COMMENT - ? 'Comment' - : 'Start discussion'; + return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; + }, + startDiscussionDescription() { + let text = 'Discuss a specific suggestion or question'; + if (this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE) { + text += ' that needs to be resolved'; + } + return `${text}.`; }, isOpen() { - return ( - this.openState === constants.OPENED || - this.openState === constants.REOPENED - ); + return this.openState === constants.OPENED || this.openState === constants.REOPENED; }, canCreateNote() { return this.getNoteableData.current_user.can_create_note; @@ -117,6 +116,9 @@ export default { endpoint() { return this.getNoteableData.create_note_path; }, + issuableTypeTitle() { + return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ? 'merge request' : 'issue'; + }, }, watch: { note(newNote) { @@ -129,9 +131,7 @@ export default { mounted() { // jQuery is needed here because it is a custom event being dispatched with jQuery. $(document).on('issuable:change', (e, isClosed) => { - this.toggleIssueLocalState( - isClosed ? constants.CLOSED : constants.REOPENED, - ); + this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED); }); this.initAutoSave(); @@ -168,6 +168,7 @@ export default { noteable_id: this.getNoteableData.id, note: this.note, }, + merge_request_diff_head_sha: this.getNoteableData.diff_head_sha, }, }; @@ -227,9 +228,7 @@ Please check your network connection and try again.`; this.toggleStateButtonLoading(false); Flash( sprintf( - __( - 'Something went wrong while closing the %{issuable}. Please try again later', - ), + __('Something went wrong while closing the %{issuable}. Please try again later'), { issuable: this.noteableDisplayName }, ), ); @@ -242,9 +241,7 @@ Please check your network connection and try again.`; this.toggleStateButtonLoading(false); Flash( sprintf( - __( - 'Something went wrong while reopening the %{issuable}. Please try again later', - ), + __('Something went wrong while reopening the %{issuable}. Please try again later'), { issuable: this.noteableDisplayName }, ), ); @@ -281,9 +278,7 @@ Please check your network connection and try again.`; }, initAutoSave() { if (this.isLoggedIn) { - const noteableType = capitalizeFirstCharacter( - convertToCamelCase(this.noteableType), - ); + const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); this.autosave = new Autosave($(this.$refs.textarea), [ 'Note', @@ -312,8 +307,8 @@ Please check your network connection and try again.`; <div> <note-signed-out-widget v-if="!isLoggedIn" /> <discussion-locked-widget - issuable-type="issue" - v-else-if="isLocked(getNoteableData) && !canCreateNote" + v-else-if="!canCreateNote" + :issuable-type="issuableTypeTitle" /> <ul v-else-if="canCreateNote" @@ -345,23 +340,23 @@ Please check your network connection and try again.`; /> <markdown-field + ref="markdownField" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" - :add-spacing-classes="false" - ref="markdownField"> + :add-spacing-classes="false"> <textarea id="note-body" + ref="textarea" + slot="textarea" + v-model="note" + :disabled="isSubmitting" name="note[note]" - class="note-textarea js-vue-comment-form + class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area js-vue-textarea" data-supports-quick-actions="true" aria-label="Description" - v-model="note" - ref="textarea" - slot="textarea" - :disabled="isSubmitting" - placeholder="Write a comment or drag your files here..." + placeholder="Write a comment or drag your files here…" @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleSave()" @keydown.ctrl.enter="handleSave()"> @@ -372,10 +367,10 @@ js-gfm-input js-autosize markdown-area js-vue-textarea" class="float-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"> <button - @click.prevent="handleSave()" :disabled="isSubmitButtonDisabled" class="btn btn-create comment-btn js-comment-button js-comment-submit-button" - type="submit"> + type="submit" + @click.prevent="handleSave()"> {{ __(commentButtonTitle) }} </button> <button @@ -423,7 +418,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <div class="description"> <strong>Start discussion</strong> <p> - Discuss a specific suggestion or question. + {{ startDiscussionDescription }} </p> </div> </button> @@ -434,20 +429,20 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" <loading-button v-if="canUpdateIssue" :loading="isToggleStateButtonLoading" - @click="handleSave(true)" :container-class="[ actionButtonClassNames, 'btn btn-comment btn-comment-and-close js-action-button' ]" :disabled="isToggleStateButtonLoading || isSubmitting" :label="issueActionButtonTitle" + @click="handleSave(true)" /> <button - type="button" v-if="note.length" - @click="discard" - class="btn btn-cancel js-note-discard"> + type="button" + class="btn btn-cancel js-note-discard" + @click="discard"> Discard draft </button> </div> diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue index 94d9dc69964..fc7b52be241 100644 --- a/app/assets/javascripts/notes/components/diff_file_header.vue +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -29,12 +29,12 @@ export default { <span> <icon name="archive" /> <strong - v-html="diffFile.submoduleLink" class="file-title-name" + v-html="diffFile.submoduleLink" ></strong> <clipboard-button - title="Copy file path to clipboard" :text="diffFile.submoduleLink" + title="Copy file path to clipboard" css-class="btn-default btn-transparent btn-clipboard" /> </span> @@ -48,16 +48,16 @@ export default { <span v-html="diffFile.blobIcon"></span> <span v-if="diffFile.renamedFile"> <strong - class="file-title-name has-tooltip" :title="diffFile.oldPath" + class="file-title-name has-tooltip" data-container="body" > {{ diffFile.oldPath }} </strong> → <strong - class="file-title-name has-tooltip" :title="diffFile.newPath" + class="file-title-name has-tooltip" data-container="body" > {{ diffFile.newPath }} @@ -66,8 +66,8 @@ export default { <strong v-else - class="file-title-name has-tooltip" :title="diffFile.oldPath" + class="file-title-name has-tooltip" data-container="body" > {{ diffFile.filePath }} @@ -78,8 +78,8 @@ export default { </component> <clipboard-button - title="Copy file path to clipboard" :text="diffFile.filePath" + title="Copy file path to clipboard" css-class="btn-default btn-transparent btn-clipboard" /> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index ee01ec85bbb..d321f2ce15e 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,13 +1,15 @@ <script> -import $ from 'jquery'; -import syntaxHighlight from '~/syntax_highlight'; +import { mapState, mapActions } from 'vuex'; import imageDiffHelper from '~/image_diff/helpers/index'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import DiffFileHeader from './diff_file_header.vue'; +import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; export default { components: { DiffFileHeader, + SkeletonLoadingContainer, }, props: { discussion: { @@ -15,7 +17,24 @@ export default { required: true, }, }, + data() { + return { + error: false, + }; + }, computed: { + ...mapState({ + noteableData: state => state.notes.noteableData, + }), + hasTruncatedDiffLines() { + return this.discussion.truncatedDiffLines && this.discussion.truncatedDiffLines.length !== 0; + }, + isDiscussionsExpanded() { + return true; // TODO: @fatihacet - Fix this. + }, + isCollapsed() { + return this.diffFile.collapsed || false; + }, isImageDiff() { return !this.diffFile.text; }, @@ -23,36 +42,46 @@ export default { const { text } = this.diffFile; return text ? 'text-file' : 'js-image-file'; }, - diffRows() { - return $(this.discussion.truncatedDiffLines); - }, diffFile() { - return convertObjectPropsToCamelCase(this.discussion.diffFile); + return convertObjectPropsToCamelCase(this.discussion.diffFile, { deep: true }); }, imageDiffHtml() { return this.discussion.imageDiffHtml; }, + currentUser() { + return this.noteableData.current_user; + }, + userColorScheme() { + return window.gon.user_color_scheme; + }, + normalizedDiffLines() { + const lines = this.discussion.truncatedDiffLines || []; + + return lines.map(line => trimFirstCharOfLineContent(convertObjectPropsToCamelCase(line))); + }, }, mounted() { if (this.isImageDiff) { const canCreateNote = false; const renderCommentBadge = true; - imageDiffHelper.initImageDiff( - this.$refs.fileHolder, - canCreateNote, - renderCommentBadge, - ); - } else { - const fileHolder = $(this.$refs.fileHolder); - this.$nextTick(() => { - syntaxHighlight(fileHolder); - }); + imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge); + } else if (!this.hasTruncatedDiffLines) { + this.fetchDiff(); } }, methods: { + ...mapActions(['fetchDiscussionDiffLines']), rowTag(html) { return html.outerHTML ? 'tr' : 'template'; }, + fetchDiff() { + this.error = false; + this.fetchDiscussionDiffLines(this.discussion) + .then(this.highlight) + .catch(() => { + this.error = true; + }); + }, }, }; </script> @@ -60,26 +89,62 @@ export default { <template> <div ref="fileHolder" - class="diff-file file-holder" :class="diffFileClass" + class="diff-file file-holder" > - <div class="js-file-title file-title file-title-flex-parent"> - <diff-file-header - :diff-file="diffFile" - /> - </div> + <diff-file-header + :diff-file="diffFile" + :current-user="currentUser" + :discussions-expanded="isDiscussionsExpanded" + :expanded="!isCollapsed" + /> <div v-if="diffFile.text" - class="diff-content code js-syntax-highlight" + :class="userColorScheme" + class="diff-content code" > <table> - <component - :is="rowTag(html)" - :class="html.className" - v-for="(html, index) in diffRows" - v-html="html.outerHTML" - :key="index" - /> + <tr + v-for="line in normalizedDiffLines" + :key="line.lineCode" + class="line_holder" + > + <td class="diff-line-num old_line">{{ line.oldLine }}</td> + <td class="diff-line-num new_line">{{ line.newLine }}</td> + <td + :class="line.type" + class="line_content" + v-html="line.richText" + > + </td> + </tr> + <tr + v-if="!hasTruncatedDiffLines" + class="line_holder line-holder-placeholder" + > + <td class="old_line diff-line-num"></td> + <td class="new_line diff-line-num"></td> + <td + v-if="error" + class="js-error-lazy-load-diff diff-loading-error-block" + > + Unable to load the diff + <button + class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button" + @click="fetchDiff" + > + Try again + </button> + </td> + <td + v-else + class="line_content js-success-lazy-load" + > + <span></span> + <skeleton-loading-container /> + <span></span> + </td> + </tr> <tr class="notes_holder"> <td class="notes_line" diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index cbe4774a360..6385b75e557 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,5 +1,5 @@ <script> -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import resolveSvg from 'icons/_icon_resolve_discussion.svg'; import resolvedSvg from 'icons/_icon_status_success_solid.svg'; import mrIssueSvg from 'icons/_icon_mr_issue.svg'; @@ -48,10 +48,14 @@ export default { this.nextDiscussionSvg = nextDiscussionSvg; }, methods: { - jumpToFirstDiscussion() { - const el = document.querySelector( - `[data-discussion-id="${this.firstUnresolvedDiscussionId}"]`, - ); + ...mapActions(['expandDiscussion']), + jumpToFirstUnresolvedDiscussion() { + const discussionId = this.firstUnresolvedDiscussionId; + if (!discussionId) { + return; + } + + const el = document.querySelector(`[data-discussion-id="${discussionId}"]`); const activeTab = window.mrTabs.currentAction; if (activeTab === 'commits' || activeTab === 'pipelines') { @@ -59,6 +63,7 @@ export default { } if (el) { + this.expandDiscussion({ discussionId }); scrollToElement(el); } }, @@ -95,9 +100,9 @@ export default { class="btn-group" role="group"> <a - :href="resolveAllDiscussionsIssuePath" v-tooltip - title="Resolve all discussions in new issue" + :href="resolveAllDiscussionsIssuePath" + :title="s__('Resolve all discussions in new issue')" data-container="body" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"> <span v-html="mrIssueSvg"></span> @@ -108,11 +113,11 @@ export default { class="btn-group" role="group"> <button - @click="jumpToFirstDiscussion" v-tooltip title="Jump to first unresolved discussion" data-container="body" - class="btn btn-default discussion-next-btn"> + class="btn btn-default discussion-next-btn" + @click="jumpToFirstUnresolvedDiscussion"> <span v-html="nextDiscussionSvg"></span> </button> </div> diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index 13283b187d1..de0a5f8489b 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -14,8 +14,8 @@ export default { <div class="disabled-comment text-center"> <span class="issuable-note-warning inline"> <icon - name="lock" :size="16" + name="lock" class="icon" /> <span> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 626b0799581..cdbbb342331 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -27,6 +27,10 @@ export default { type: Number, required: true, }, + noteUrl: { + type: String, + required: true, + }, accessLevel: { type: String, required: false, @@ -48,6 +52,11 @@ export default { type: Boolean, required: true, }, + canResolve: { + type: Boolean, + required: false, + default: false, + }, resolvable: { type: Boolean, required: false, @@ -125,16 +134,16 @@ export default { {{ accessLevel }} </span> <div - v-if="resolvable" + v-if="canResolve" class="note-actions-item"> <button v-tooltip - @click="onResolve" :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :title="resolveButtonTitle" :aria-label="resolveButtonTitle" type="button" - class="line-resolve-btn note-action-button"> + class="line-resolve-btn note-action-button" + @click="onResolve"> <template v-if="!isResolving"> <div v-if="isResolved" @@ -164,16 +173,16 @@ export default { > <loading-icon :inline="true" /> <span - v-html="emojiSmiling" - class="link-highlight award-control-icon-neutral"> + class="link-highlight award-control-icon-neutral" + v-html="emojiSmiling"> </span> <span - v-html="emojiSmiley" - class="link-highlight award-control-icon-positive"> + class="link-highlight award-control-icon-positive" + v-html="emojiSmiley"> </span> <span - v-html="emojiSmile" - class="link-highlight award-control-icon-super-positive"> + class="link-highlight award-control-icon-super-positive" + v-html="emojiSmile"> </span> </a> </div> @@ -181,16 +190,16 @@ export default { v-if="canEdit" class="note-actions-item"> <button - @click="onEdit" v-tooltip type="button" title="Edit comment" class="note-action-button js-note-edit btn btn-transparent" data-container="body" - data-placement="bottom"> + data-placement="bottom" + @click="onEdit"> <span - v-html="editSvg" - class="link-highlight"> + class="link-highlight" + v-html="editSvg"> </span> </button> </div> @@ -216,11 +225,20 @@ export default { Report as abuse </a> </li> + <li> + <button + :data-clipboard-text="noteUrl" + type="button" + css-class="btn-default btn-transparent" + > + Copy link + </button> + </li> <li v-if="canEdit"> <button - @click.prevent="onDelete" class="btn btn-transparent js-note-delete js-note-delete" - type="button"> + type="button" + @click.prevent="onDelete"> <span class="text-danger"> Delete comment </span> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index e8fd155a1ee..225d9f18612 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -199,10 +199,11 @@ export default { :key="index" :class="getAwardClassBindings(awardList, awardName)" :title="awardTitle(awardList)" - @click="handleAward(awardName)" class="btn award-control" + data-boundary="viewport" data-placement="bottom" - type="button"> + type="button" + @click="handleAward(awardName)"> <span v-html="getAwardHTML(awardName)"></span> <span class="award-control-text js-counter"> {{ awardList.length }} @@ -217,19 +218,20 @@ export default { class="award-control btn js-add-award" title="Add reaction" aria-label="Add reaction" + data-boundary="viewport" data-placement="bottom" type="button"> <span - v-html="emojiSmiling" - class="award-control-icon award-control-icon-neutral"> + class="award-control-icon award-control-icon-neutral" + v-html="emojiSmiling"> </span> <span - v-html="emojiSmiley" - class="award-control-icon award-control-icon-positive"> + class="award-control-icon award-control-icon-positive" + v-html="emojiSmiley"> </span> <span - v-html="emojiSmile" - class="award-control-icon award-control-icon-super-positive"> + class="award-control-icon award-control-icon-super-positive" + v-html="emojiSmile"> </span> <i aria-hidden="true" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 0cb626c14f4..d2db68df98e 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -40,7 +40,7 @@ export default { this.initTaskList(); if (this.isEditing) { - this.initAutoSave(this.note.noteable_type); + this.initAutoSave(this.note); } }, updated() { @@ -49,7 +49,7 @@ export default { if (this.isEditing) { if (!this.autosave) { - this.initAutoSave(this.note.noteable_type); + this.initAutoSave(this.note); } else { this.setAutoSave(); } @@ -72,7 +72,7 @@ export default { this.$emit('handleFormUpdate', note, parentElement, callback); }, formCancelHandler(shouldConfirm, isDirty) { - this.$emit('cancelFormEdition', shouldConfirm, isDirty); + this.$emit('cancelForm', shouldConfirm, isDirty); }, }, }; @@ -80,20 +80,20 @@ export default { <template> <div - :class="{ 'js-task-list-container': canEdit }" ref="note-body" + :class="{ 'js-task-list-container': canEdit }" class="note-body"> <div - v-html="note.note_html" - class="note-text md"></div> + class="note-text md" + v-html="note.note_html"></div> <note-form v-if="isEditing" ref="noteForm" - @handleFormUpdate="handleFormUpdate" - @cancelFormEdition="formCancelHandler" :is-editing="isEditing" :note-body="noteBody" :note-id="note.id" + @handleFormUpdate="handleFormUpdate" + @cancelForm="formCancelHandler" /> <textarea v-if="canEdit" @@ -105,6 +105,7 @@ export default { :edited-at="note.last_edited_at" :edited-by="note.last_edited_by" action-text="Edited" + class="note_edited_ago" /> <note-awards-list v-if="note.award_emoji.length" diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue index 2dc39d1a186..391bb2ae179 100644 --- a/app/assets/javascripts/notes/components/note_edited_text.vue +++ b/app/assets/javascripts/notes/components/note_edited_text.vue @@ -11,14 +11,20 @@ export default { type: String, required: true, }, + actionDetailText: { + type: String, + required: false, + default: '', + }, editedAt: { type: String, - required: true, + required: false, + default: null, }, editedBy: { type: Object, required: false, - default: () => ({}), + default: null, }, className: { type: String, @@ -33,13 +39,14 @@ export default { <div :class="className"> {{ actionText }} <template v-if="editedBy"> - {{ s__('ByAuthor|by') }} + by <a :href="editedBy.path" class="js-vue-author author_link"> {{ editedBy.name }} </a> </template> + {{ actionDetailText }} <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index c59a2e7a406..a4e3faa5d75 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -29,7 +29,7 @@ export default { required: false, default: 'Save comment', }, - note: { + discussion: { type: Object, required: false, default: () => ({}), @@ -38,6 +38,11 @@ export default { type: Boolean, required: true, }, + lineCode: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -66,9 +71,7 @@ export default { return this.getNotesDataByProp('markdownDocsPath'); }, quickActionsDocsPath() { - return !this.isEditing - ? this.getNotesDataByProp('quickActionsDocsPath') - : undefined; + return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; }, currentUserId() { return this.getUserDataByProp('id'); @@ -95,24 +98,17 @@ export default { const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; - this.$emit( - 'handleFormUpdate', - this.updatedNoteBody, - this.$refs.editNoteForm, - () => { - this.isSubmitting = false; + this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => { + this.isSubmitting = false; - if (shouldResolve) { - this.resolveHandler(beforeSubmitDiscussionState); - } - }, - ); + if (shouldResolve) { + this.resolveHandler(beforeSubmitDiscussionState); + } + }); }, editMyLastNote() { if (this.updatedNoteBody === '') { - const lastNoteInDiscussion = this.getDiscussionLastNote( - this.updatedNoteBody, - ); + const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); if (lastNoteInDiscussion) { eventHub.$emit('enterEditMode', { @@ -123,11 +119,7 @@ export default { }, cancelHandler(shouldConfirm = false) { // Sends information about confirm message and if the textarea has changed - this.$emit( - 'cancelFormEdition', - shouldConfirm, - this.noteBody !== this.updatedNoteBody, - ); + this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody); }, }, }; @@ -136,7 +128,7 @@ export default { <template> <div ref="editNoteForm" - class="note-edit-form current-note-edit-form"> + class="note-edit-form current-note-edit-form js-discussion-note-form"> <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger"> @@ -150,7 +142,10 @@ export default { to ensure information is not lost. </div> <div class="flash-container timeline-content"></div> - <form class="edit-note common-note-form js-quick-submit gfm-form"> + <form + :data-line-code="lineCode" + class="edit-note common-note-form js-quick-submit gfm-form" + > <issue-warning v-if="hasWarning(getNoteableData)" @@ -165,15 +160,15 @@ export default { :add-spacing-classes="false"> <textarea id="note_note" + ref="textarea" + slot="textarea" + :data-supports-quick-actions="!isEditing" + v-model="updatedNoteBody" name="note[note]" - class="note-textarea js-gfm-input + class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" - :data-supports-quick-actions="!isEditing" aria-label="Description" - v-model="updatedNoteBody" - ref="textarea" - slot="textarea" - placeholder="Write a comment or drag your files here..." + placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleUpdate()" @keydown.ctrl.enter="handleUpdate()" @keydown.up="editMyLastNote()" @@ -182,24 +177,24 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" </markdown-field> <div class="note-form-actions clearfix"> <button - type="button" - @click="handleUpdate()" :disabled="isDisabled" - class="js-vue-issue-save btn btn-save"> + type="button" + class="js-vue-issue-save btn btn-save js-comment-button " + @click="handleUpdate()"> {{ saveButtonTitle }} </button> <button - v-if="note.resolvable" - @click.prevent="handleUpdate(true)" + v-if="discussion.resolvable" class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" + @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} </button> <button - @click="cancelHandler()" - class="btn btn-cancel note-edit-cancel" - type="button"> - Cancel + class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" + type="button" + @click="cancelHandler()"> + {{ __('Discard draft') }} </button> </div> </form> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index a4081957207..ee3580895df 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -20,11 +20,6 @@ export default { required: false, default: '', }, - actionTextHtml: { - type: String, - required: false, - default: '', - }, noteId: { type: Number, required: true, @@ -66,9 +61,9 @@ export default { v-if="includeToggle" class="discussion-actions"> <button - @click="handleToggle" class="note-action-button discussion-toggle-button js-vue-toggle-button" - type="button"> + type="button" + @click="handleToggle"> <i :class="toggleChevronClass" class="fa" @@ -88,18 +83,16 @@ export default { <template v-if="actionText"> {{ actionText }} </template> - <span - v-if="actionTextHtml" - v-html="actionTextHtml" - class="system-note-message"> + <span class="system-note-message"> + <slot></slot> </span> <span class="system-note-separator"> · </span> <a :href="noteTimestampLink" - @click="updateTargetNoteHash" - class="note-timestamp system-note-separator"> + class="note-timestamp system-note-separator" + @click="updateTargetNoteHash"> <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 7f5aacaa3a2..bee635398b3 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,7 +1,11 @@ <script> +import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; import resolveDiscussionsSvg from 'icons/_icon_mr_issue.svg'; import nextDiscussionsSvg from 'icons/_next_discussion.svg'; +import { convertObjectPropsToCamelCase, scrollToElement } from '~/lib/utils/common_utils'; +import { truncateSha } from '~/lib/utils/text_utility'; +import systemNote from '~/vue_shared/components/notes/system_note.vue'; import Flash from '../../flash'; import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -17,9 +21,9 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import tooltip from '../../vue_shared/directives/tooltip'; -import { scrollToElement } from '../../lib/utils/common_utils'; export default { + name: 'NoteableDiscussion', components: { noteableNote, diffWithNote, @@ -30,16 +34,32 @@ export default { noteForm, placeholderNote, placeholderSystemNote, + systemNote, }, directives: { tooltip, }, mixins: [autosave, noteable, resolvable], props: { - note: { + discussion: { type: Object, required: true, }, + renderHeader: { + type: Boolean, + required: false, + default: true, + }, + renderDiffFile: { + type: Boolean, + required: false, + default: true, + }, + alwaysExpanded: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -53,19 +73,27 @@ export default { 'getNoteableData', 'discussionCount', 'resolvedDiscussionCount', + 'allDiscussions', 'unresolvedDiscussions', ]), - discussion() { + transformedDiscussion() { return { - ...this.note.notes[0], - truncatedDiffLines: this.note.truncated_diff_lines, - diffFile: this.note.diff_file, - diffDiscussion: this.note.diff_discussion, - imageDiffHtml: this.note.image_diff_html, + ...this.discussion.notes[0], + truncatedDiffLines: this.discussion.truncated_diff_lines || [], + truncatedDiffLinesPath: this.discussion.truncated_diff_lines_path, + diffFile: this.discussion.diff_file, + diffDiscussion: this.discussion.diff_discussion, + imageDiffHtml: this.discussion.image_diff_html, + active: this.discussion.active, + discussionPath: this.discussion.discussion_path, + resolved: this.discussion.resolved, + resolvedBy: this.discussion.resolved_by, + resolvedByPush: this.discussion.resolved_by_push, + resolvedAt: this.discussion.resolved_at, }; }, author() { - return this.discussion.author; + return this.transformedDiscussion.author; }, canReply() { return this.getNoteableData.current_user.can_create_note; @@ -74,7 +102,7 @@ export default { return this.getNoteableData.create_note_path; }, lastUpdatedBy() { - const { notes } = this.note; + const { notes } = this.discussion; if (notes.length > 1) { return notes[notes.length - 1].author; @@ -83,7 +111,7 @@ export default { return null; }, lastUpdatedAt() { - const { notes } = this.note; + const { notes } = this.discussion; if (notes.length > 1) { return notes[notes.length - 1].created_at; @@ -91,27 +119,40 @@ export default { return null; }, - hasUnresolvedDiscussion() { - return this.unresolvedDiscussions.length > 0; + resolvedText() { + return this.transformedDiscussion.resolvedByPush ? 'Automatically resolved' : 'Resolved'; + }, + hasMultipleUnresolvedDiscussions() { + return this.unresolvedDiscussions.length > 1; + }, + shouldRenderDiffs() { + const { diffDiscussion, diffFile } = this.transformedDiscussion; + + return diffDiscussion && diffFile && this.renderDiffFile; }, wrapperComponent() { - return this.discussion.diffDiscussion && this.discussion.diffFile - ? diffWithNote - : 'div'; + return this.shouldRenderDiffs ? diffWithNote : 'div'; + }, + wrapperComponentProps() { + if (this.shouldRenderDiffs) { + return { discussion: convertObjectPropsToCamelCase(this.discussion) }; + } + + return {}; }, wrapperClass() { - return this.isDiffDiscussion ? '' : 'card'; + return this.isDiffDiscussion ? '' : 'card discussion-wrapper'; }, }, mounted() { if (this.isReplying) { - this.initAutoSave(this.discussion.noteable_type); + this.initAutoSave(this.transformedDiscussion); } }, updated() { if (this.isReplying) { if (!this.autosave) { - this.initAutoSave(this.discussion.noteable_type); + this.initAutoSave(this.transformedDiscussion); } else { this.setAutoSave(); } @@ -127,7 +168,9 @@ export default { 'toggleDiscussion', 'removePlaceholderNotes', 'toggleResolveNote', + 'expandDiscussion', ]), + truncateSha, componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -136,23 +179,25 @@ export default { return placeholderNote; } + if (note.system) { + return systemNote; + } + return noteableNote; }, componentData(note) { - return note.isPlaceholderNote ? this.note.notes[0] : note; + return note.isPlaceholderNote ? this.discussion.notes[0] : note; }, toggleDiscussionHandler() { - this.toggleDiscussion({ discussionId: this.note.id }); + this.toggleDiscussion({ discussionId: this.discussion.id }); }, showReplyForm() { this.isReplying = true; }, cancelReplyForm(shouldConfirm) { if (shouldConfirm && this.$refs.noteForm.isDirty) { - const msg = 'Are you sure you want to cancel creating this comment?'; - // eslint-disable-next-line no-alert - if (!confirm(msg)) { + if (!window.confirm('Are you sure you want to cancel creating this comment?')) { return; } } @@ -161,18 +206,23 @@ export default { this.isReplying = false; }, saveReply(noteText, form, callback) { + const postData = { + in_reply_to_discussion_id: this.discussion.reply_id, + target_type: this.getNoteableData.targetType, + note: { note: noteText }, + }; + + if (this.discussion.for_commit) { + postData.note_project_id = this.discussion.project_id; + } + const replyData = { endpoint: this.newNotePath, flashContainer: this.$el, - data: { - in_reply_to_discussion_id: this.note.reply_id, - target_type: this.noteableType, - target_id: this.discussion.noteable_id, - note: { note: noteText }, - }, + data: postData, }; - this.isReplying = false; + this.isReplying = false; this.saveNote(replyData) .then(() => { this.resetAutoSave(); @@ -190,15 +240,19 @@ Please check your network connection and try again.`; }); }); }, - jumpToDiscussion() { + jumpToNextDiscussion() { + const discussionIds = this.allDiscussions.map(d => d.id); const unresolvedIds = this.unresolvedDiscussions.map(d => d.id); - const index = unresolvedIds.indexOf(this.note.id); + const currentIndex = discussionIds.indexOf(this.discussion.id); + const remainingAfterCurrent = discussionIds.slice(currentIndex + 1); + const nextIndex = _.findIndex(remainingAfterCurrent, id => unresolvedIds.indexOf(id) > -1); - if (index >= 0 && index !== unresolvedIds.length) { - const nextId = unresolvedIds[index + 1]; + if (nextIndex > -1) { + const nextId = remainingAfterCurrent[nextIndex]; const el = document.querySelector(`[data-discussion-id="${nextId}"]`); if (el) { + this.expandDiscussion({ discussionId: nextId }); scrollToElement(el); } } @@ -208,9 +262,7 @@ Please check your network connection and try again.`; </script> <template> - <li - :data-discussion-id="note.id" - class="note note-discussion timeline-entry"> + <li class="note note-discussion timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link @@ -221,20 +273,52 @@ Please check your network connection and try again.`; /> </div> <div class="timeline-content"> - <div class="discussion"> - <div class="discussion-header"> + <div + :data-discussion-id="transformedDiscussion.discussion_id" + class="discussion js-discussion-container" + > + <div + v-if="renderHeader" + class="discussion-header" + > <note-header :author="author" - :created-at="discussion.created_at" - :note-id="discussion.id" + :created-at="transformedDiscussion.created_at" + :note-id="transformedDiscussion.id" :include-toggle="true" - :expanded="note.expanded" + :expanded="discussion.expanded" @toggleHandler="toggleDiscussionHandler" - action-text="started a discussion" - class="discussion" + > + <template v-if="transformedDiscussion.diffDiscussion"> + started a discussion on + <a :href="transformedDiscussion.discussionPath"> + <template v-if="transformedDiscussion.active"> + the diff + </template> + <template v-else> + an old version of the diff + </template> + </a> + </template> + <template v-else-if="discussion.for_commit"> + started a discussion on commit + <a :href="discussion.discussion_path"> + {{ truncateSha(discussion.commit_id) }} + </a> + </template> + <template v-else> + started a discussion + </template> + </note-header> + <note-edited-text + v-if="transformedDiscussion.resolved" + :edited-at="transformedDiscussion.resolvedAt" + :edited-by="transformedDiscussion.resolvedBy" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" /> <note-edited-text - v-if="lastUpdatedAt" + v-else-if="lastUpdatedAt" :edited-at="lastUpdatedAt" :edited-by="lastUpdatedBy" action-text="Last updated" @@ -242,17 +326,17 @@ Please check your network connection and try again.`; /> </div> <div - v-if="note.expanded" + v-if="discussion.expanded || alwaysExpanded" class="discussion-body"> <component :is="wrapperComponent" - :discussion="discussion" + v-bind="wrapperComponentProps" :class="wrapperClass" > <div class="discussion-notes"> <ul class="notes"> <component - v-for="note in note.notes" + v-for="note in discussion.notes" :is="componentName(note)" :note="componentData(note)" :key="note.id" @@ -260,28 +344,29 @@ Please check your network connection and try again.`; </ul> <div :class="{ 'is-replying': isReplying }" - class="discussion-reply-holder"> + class="discussion-reply-holder" + > <template v-if="!isReplying && canReply"> <div class="btn-group d-flex discussion-with-resolve-btn" role="group"> <div - class="btn-group" + class="btn-group w-100" role="group"> <button - @click="showReplyForm" type="button" - class="js-vue-discussion-reply btn btn-text-field" - title="Add a reply">Reply...</button> + class="js-vue-discussion-reply btn btn-text-field mr-2" + title="Add a reply" + @click="showReplyForm">Reply...</button> </div> <div - v-if="note.resolvable" + v-if="discussion.resolvable" class="btn-group" role="group"> <button - @click="resolveHandler()" type="button" - class="btn btn-default" + class="btn btn-default mr-2" + @click="resolveHandler()" > <i v-if="isResolving" @@ -292,7 +377,7 @@ Please check your network connection and try again.`; </button> </div> <div - v-if="note.resolvable" + v-if="discussion.resolvable" class="btn-group discussion-actions" role="group" > @@ -301,26 +386,26 @@ Please check your network connection and try again.`; class="btn-group" role="group"> <a - :href="note.resolve_with_issue_path" v-tooltip + :href="discussion.resolve_with_issue_path" + :title="s__('MergeRequests|Resolve this discussion in a new issue')" class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" - title="Resolve this discussion in a new issue" data-container="body" > <span v-html="resolveDiscussionsSvg"></span> </a> </div> <div - v-if="hasUnresolvedDiscussion" + v-if="hasMultipleUnresolvedDiscussions" class="btn-group" role="group"> <button - @click="jumpToDiscussion" v-tooltip class="btn btn-default discussion-next-btn" title="Jump to next unresolved discussion" data-container="body" + @click="jumpToNextDiscussion" > <span v-html="nextDiscussionsSvg"></span> </button> @@ -330,12 +415,12 @@ Please check your network connection and try again.`; </template> <note-form v-if="isReplying" - save-button-title="Comment" - :note="note" + ref="noteForm" + :discussion="discussion" :is-editing="false" + save-button-title="Comment" @handleFormUpdate="saveReply" - @cancelFormEdition="cancelReplyForm" - ref="noteForm" /> + @cancelForm="cancelReplyForm" /> <note-signed-out-widget v-if="!canReply" /> </div> </div> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 566f5c68e66..4ebeb5599f2 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -12,6 +12,7 @@ import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; export default { + name: 'NoteableNote', components: { userAvatarLink, noteHeader, @@ -34,26 +35,31 @@ export default { }; }, computed: { - ...mapGetters(['targetNoteHash', 'getUserData']), + ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']), author() { return this.note.author; }, classNameBindings() { return { + [`note-row-${this.note.id}`]: true, 'is-editing': this.isEditing && !this.isRequesting, 'is-requesting being-posted': this.isRequesting, 'disabled-content': this.isDeleting, - target: this.targetNoteHash === this.noteAnchorId, + target: this.isTarget, }; }, + canResolve() { + return this.note.resolvable && !!this.getUserData.id; + }, canReportAsAbuse() { - return ( - this.note.report_abuse_path && this.author.id !== this.getUserData.id - ); + return this.note.report_abuse_path && this.author.id !== this.getUserData.id; }, noteAnchorId() { return `note_${this.note.id}`; }, + isTarget() { + return this.targetNoteHash === this.noteAnchorId; + }, }, created() { @@ -65,19 +71,20 @@ export default { }); }, + mounted() { + if (this.isTarget) { + this.scrollToNoteIfNeeded($(this.$el)); + } + }, + methods: { - ...mapActions([ - 'deleteNote', - 'updateNote', - 'toggleResolveNote', - 'scrollToNoteIfNeeded', - ]), + ...mapActions(['deleteNote', 'updateNote', 'toggleResolveNote', 'scrollToNoteIfNeeded']), editHandler() { this.isEditing = true; }, deleteHandler() { // eslint-disable-next-line no-alert - if (confirm('Are you sure you want to delete this comment?')) { + if (window.confirm('Are you sure you want to delete this comment?')) { this.isDeleting = true; this.deleteNote(this.note) @@ -85,9 +92,7 @@ export default { this.isDeleting = false; }) .catch(() => { - Flash( - 'Something went wrong while deleting your note. Please try again.', - ); + Flash('Something went wrong while deleting your note. Please try again.'); this.isDeleting = false; }); } @@ -96,7 +101,7 @@ export default { const data = { endpoint: this.note.path, note: { - target_type: this.noteableType, + target_type: this.getNoteableData.targetType, target_id: this.note.noteable_id, note: { note: noteText }, }, @@ -118,8 +123,7 @@ export default { this.isRequesting = false; this.isEditing = true; this.$nextTick(() => { - const msg = - 'Something went wrong while editing your comment. Please try again.'; + const msg = 'Something went wrong while editing your comment. Please try again.'; Flash(msg, 'alert', this.$el); this.recoverNoteContent(noteText); callback(); @@ -129,8 +133,7 @@ export default { formCancelHandler(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { // eslint-disable-next-line no-alert - if (!confirm('Are you sure you want to cancel editing this comment?')) - return; + if (!window.confirm('Are you sure you want to cancel editing this comment?')) return; } this.$refs.noteBody.resetAutoSave(); if (this.oldContent) { @@ -143,7 +146,7 @@ export default { // we need to do this to prevent noteForm inconsistent content warning // this is something we intentionally do so we need to recover the content this.note.note = noteText; - this.$refs.noteBody.$refs.noteForm.note.note = noteText; + this.$refs.noteBody.note.note = noteText; }, }, }; @@ -151,10 +154,12 @@ export default { <template> <li - class="note timeline-entry" :id="noteAnchorId" :class="classNameBindings" - :data-award-url="note.toggle_award_path"> + :data-award-url="note.toggle_award_path" + :data-note-id="note.id" + class="note timeline-entry" + > <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link @@ -170,16 +175,17 @@ export default { :author="author" :created-at="note.created_at" :note-id="note.id" - action-text="commented" /> <note-actions :author-id="author.id" :note-id="note.id" + :note-url="note.noteable_note_url" :access-level="note.human_access" :can-edit="note.current_user.can_edit" :can-award-emoji="note.current_user.can_award_emoji" :can-delete="note.current_user.can_edit" :can-report-as-abuse="canReportAsAbuse" + :can-resolve="note.current_user.can_resolve" :report-abuse-path="note.report_abuse_path" :resolvable="note.resolvable" :is-resolved="note.resolved" @@ -191,12 +197,12 @@ export default { /> </div> <note-body + ref="noteBody" :note="note" :can-edit="note.current_user.can_edit" :is-editing="isEditing" @handleFormUpdate="formUpdateHandler" - @cancelFormEdition="formCancelHandler" - ref="noteBody" + @cancelForm="formCancelHandler" /> </div> </div> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index ebfc827ac57..a8995021699 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,10 +1,9 @@ <script> -import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { getLocationHash } from '../../lib/utils/url_utility'; import Flash from '../../flash'; -import store from '../stores/'; import * as constants from '../constants'; +import eventHub from '../event_hub'; import noteableNote from './noteable_note.vue'; import noteableDiscussion from './noteable_discussion.vue'; import systemNote from '../../vue_shared/components/notes/system_note.vue'; @@ -39,19 +38,23 @@ export default { required: false, default: () => ({}), }, + shouldShow: { + type: Boolean, + required: false, + default: true, + }, }, - store, data() { return { isLoading: true, }; }, computed: { - ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), + ...mapGetters(['isNotesFetched', 'discussions', 'getNotesDataByProp', 'discussionCount']), noteableType() { return this.noteableData.noteableType; }, - allNotes() { + allDiscussions() { if (this.isLoading) { const totalNotes = parseInt(this.notesData.totalNotes, 10) || 0; @@ -59,36 +62,40 @@ export default { isSkeletonNote: true, }); } - return this.notes; + + return this.discussions; + }, + }, + watch: { + shouldShow() { + if (!this.isNotesFetched) { + this.fetchNotes(); + } }, }, created() { this.setNotesData(this.notesData); this.setNoteableData(this.noteableData); this.setUserData(this.userData); + this.setTargetNoteHash(getLocationHash()); + eventHub.$once('fetchNotesData', this.fetchNotes); }, mounted() { - this.fetchNotes(); - - const parentElement = this.$el.parentElement; + if (this.shouldShow) { + this.fetchNotes(); + } - if ( - parentElement && - parentElement.classList.contains('js-vue-notes-event') - ) { + const { parentElement } = this.$el; + if (parentElement && parentElement.classList.contains('js-vue-notes-event')) { parentElement.addEventListener('toggleAward', event => { const { awardName, noteId } = event.detail; this.actionToggleAward({ awardName, noteId }); }); } - document.addEventListener('refreshVueNotes', this.fetchNotes); - }, - beforeDestroy() { - document.removeEventListener('refreshVueNotes', this.fetchNotes); }, methods: { ...mapActions({ - actionFetchNotes: 'fetchNotes', + fetchDiscussions: 'fetchDiscussions', poll: 'poll', actionToggleAward: 'toggleAward', scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', @@ -97,38 +104,42 @@ export default { setUserData: 'setUserData', setLastFetchedAt: 'setLastFetchedAt', setTargetNoteHash: 'setTargetNoteHash', + toggleDiscussion: 'toggleDiscussion', + setNotesFetchedState: 'setNotesFetchedState', }), - getComponentName(note) { - if (note.isSkeletonNote) { + getComponentName(discussion) { + if (discussion.isSkeletonNote) { return skeletonLoadingContainer; } - if (note.isPlaceholderNote) { - if (note.placeholderType === constants.SYSTEM_NOTE) { + if (discussion.isPlaceholderNote) { + if (discussion.placeholderType === constants.SYSTEM_NOTE) { return placeholderSystemNote; } return placeholderNote; - } else if (note.individual_note) { - return note.notes[0].system ? systemNote : noteableNote; + } else if (discussion.individual_note) { + return discussion.notes[0].system ? systemNote : noteableNote; } return noteableDiscussion; }, - getComponentData(note) { - return note.individual_note ? note.notes[0] : note; + getComponentData(discussion) { + return discussion.individual_note ? { note: discussion.notes[0] } : { discussion }; }, fetchNotes() { - return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) - .then(() => this.initPolling()) + return this.fetchDiscussions(this.getNotesDataByProp('discussionsPath')) + .then(() => { + this.initPolling(); + }) .then(() => { this.isLoading = false; + this.setNotesFetchedState(true); }) .then(() => this.$nextTick()) .then(() => this.checkLocationHash()) .catch(() => { this.isLoading = false; - Flash( - 'Something went wrong while fetching comments. Please try again.', - ); + this.setNotesFetchedState(true); + Flash('Something went wrong while fetching comments. Please try again.'); }); }, initPolling() { @@ -143,11 +154,19 @@ export default { }, checkLocationHash() { const hash = getLocationHash(); - const element = document.getElementById(hash); + const noteId = hash && hash.replace(/^note_/, ''); - if (hash && element) { - this.setTargetNoteHash(hash); - this.scrollToNoteIfNeeded($(element)); + if (noteId) { + this.discussions.forEach(discussion => { + if (discussion.notes) { + discussion.notes.forEach(note => { + if (`${note.id}` === `${noteId}`) { + // FIXME: this modifies the store state without using a mutation/action + Object.assign(discussion, { expanded: true }); + } + }); + } + }); } }, }, @@ -155,16 +174,19 @@ export default { </script> <template> - <div id="notes"> + <div + v-show="shouldShow" + id="notes" + > <ul id="notes-list" - class="notes main-notes-list timeline"> - + class="notes main-notes-list timeline" + > <component - v-for="note in allNotes" - :is="getComponentName(note)" - :note="getComponentData(note)" - :key="note.id" + v-for="discussion in allDiscussions" + :is="getComponentName(discussion)" + v-bind="getComponentData(discussion)" + :key="discussion.id" /> </ul> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index c4de4826eda..2c3e07c0506 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -11,9 +11,10 @@ export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const ISSUE_NOTEABLE_TYPE = 'issue'; export const EPIC_NOTEABLE_TYPE = 'epic'; -export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; +export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const RESOLVE_NOTE_METHOD_NAME = 'post'; +export const DESCRIPTION_TYPE = 'changed the description'; export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index e4121f151db..eed3a82854d 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,46 +1,49 @@ import Vue from 'vue'; import notesApp from './components/notes_app.vue'; +import createStore from './stores'; -document.addEventListener( - 'DOMContentLoaded', - () => - new Vue({ - el: '#js-vue-notes', - components: { - notesApp, - }, - data() { - const notesDataset = document.getElementById('js-vue-notes').dataset; - const parsedUserData = JSON.parse(notesDataset.currentUserData); - const noteableData = JSON.parse(notesDataset.noteableData); - let currentUserData = {}; +document.addEventListener('DOMContentLoaded', () => { + const store = createStore(); - noteableData.noteableType = notesDataset.noteableType; + return new Vue({ + el: '#js-vue-notes', + components: { + notesApp, + }, + store, + data() { + const notesDataset = document.getElementById('js-vue-notes').dataset; + const parsedUserData = JSON.parse(notesDataset.currentUserData); + const noteableData = JSON.parse(notesDataset.noteableData); + let currentUserData = {}; - if (parsedUserData) { - currentUserData = { - id: parsedUserData.id, - name: parsedUserData.name, - username: parsedUserData.username, - avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, - path: parsedUserData.path, - }; - } + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; - return { - noteableData, - currentUserData, - notesData: JSON.parse(notesDataset.notesData), + if (parsedUserData) { + currentUserData = { + id: parsedUserData.id, + name: parsedUserData.name, + username: parsedUserData.username, + avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, + path: parsedUserData.path, }; - }, - render(createElement) { - return createElement('notes-app', { - props: { - noteableData: this.noteableData, - notesData: this.notesData, - userData: this.currentUserData, - }, - }); - }, - }), -); + } + + return { + noteableData, + currentUserData, + notesData: JSON.parse(notesDataset.notesData), + }; + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index 3dff715905f..36cc8d5d056 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -4,11 +4,11 @@ import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { - initAutoSave(noteableType) { + initAutoSave(noteable) { this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), [ 'Note', - capitalizeFirstCharacter(noteableType), - this.note.id, + capitalizeFirstCharacter(noteable.noteable_type), + noteable.id, ]); }, resetAutoSave() { diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js index b68543d71c8..bf1cd6fe5a8 100644 --- a/app/assets/javascripts/notes/mixins/noteable.js +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -1,15 +1,10 @@ import * as constants from '../constants'; export default { - props: { - note: { - type: Object, - required: true, - }, - }, computed: { noteableType() { - return constants.NOTEABLE_TYPE_MAPPING[this.note.noteable_type]; + const note = this.discussion ? this.discussion.notes[0] : this.note; + return constants.NOTEABLE_TYPE_MAPPING[note.noteable_type]; }, }, }; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index f79049b85f6..cd8394e0619 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -2,42 +2,39 @@ import Flash from '~/flash'; import { __ } from '~/locale'; export default { - props: { - note: { - type: Object, - required: true, - }, - }, computed: { discussionResolved() { - const { notes, resolved } = this.note; + if (this.discussion) { + const { notes, resolved } = this.discussion; + + if (notes) { + // Decide resolved state using store. Only valid for discussions. + return notes.filter(note => !note.system).every(note => note.resolved); + } - if (notes) { - // Decide resolved state using store. Only valid for discussions. - return notes.every(note => note.resolved && !note.system); + return resolved; } - return resolved; + return this.note.resolved; }, resolveButtonTitle() { if (this.updatedNoteBody) { if (this.discussionResolved) { - return __('Comment and unresolve discussion'); + return __('Comment & unresolve discussion'); } - return __('Comment and resolve discussion'); + return __('Comment & resolve discussion'); } - return this.discussionResolved - ? __('Unresolve discussion') - : __('Resolve discussion'); + + return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); }, }, methods: { resolveHandler(resolvedState = false) { this.isResolving = true; - const endpoint = this.note.resolve_path || `${this.note.path}/resolve`; const isResolved = this.discussionResolved || resolvedState; const discussion = this.resolveAsThread; + const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; this.toggleResolveNote({ endpoint, isResolved, discussion }) .then(() => { @@ -45,9 +42,8 @@ export default { }) .catch(() => { this.isResolving = false; - const msg = __( - 'Something went wrong while resolving this discussion. Please try again.', - ); + + const msg = __('Something went wrong while resolving this discussion. Please try again.'); Flash(msg, 'alert', this.$el); }); }, diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index 7c623aac6ed..f5dce94caad 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -5,7 +5,7 @@ import * as constants from '../constants'; Vue.use(VueResource); export default { - fetchNotes(endpoint) { + fetchDiscussions(endpoint) { return Vue.http.get(endpoint); }, deleteNote(endpoint) { @@ -22,15 +22,13 @@ export default { }, toggleResolveNote(endpoint, isResolved) { const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; - const method = isResolved - ? UNRESOLVE_NOTE_METHOD_NAME - : RESOLVE_NOTE_METHOD_NAME; + const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME; return Vue.http[method](endpoint); }, poll(data = {}) { const endpoint = data.notesData.notesPath; - const lastFetchedAt = data.lastFetchedAt; + const { lastFetchedAt } = data; const options = { headers: { 'X-Last-Fetched-At': lastFetchedAt ? `${lastFetchedAt}` : undefined, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index b2222476924..671fa4d7d22 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; import Visibility from 'visibilityjs'; import Flash from '../../flash'; import Poll from '../../lib/utils/poll'; @@ -12,20 +13,32 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils'; let eTagPoll; +export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data); + export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_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); + +export const setInitialNotes = ({ commit }, discussions) => + commit(types.SET_INITIAL_DISCUSSIONS, discussions); + export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); + +export const setNotesFetchedState = ({ commit }, state) => + commit(types.SET_NOTES_FETCHED_STATE, state); + export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); -export const fetchNotes = ({ commit }, path) => +export const fetchDiscussions = ({ commit }, path) => service - .fetchNotes(path) + .fetchDiscussions(path) .then(res => res.json()) - .then(res => { - commit(types.SET_INITIAL_NOTES, res); + .then(discussions => { + commit(types.SET_INITIAL_DISCUSSIONS, discussions); }); export const deleteNote = ({ commit }, note) => @@ -121,7 +134,8 @@ export const toggleIssueLocalState = ({ commit }, newState) => { }; export const saveNote = ({ commit, dispatch }, noteData) => { - const { note } = noteData.data.note; + // For MR discussuions we need to post as `note[note]` and issue we use `note.note`. + const note = noteData.data['note[note]'] || noteData.data.note.note; let placeholderText = note; const hasQuickActions = utils.hasQuickActions(placeholderText); const replyId = noteData.data.in_reply_to_discussion_id; @@ -192,7 +206,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }); }; -const pollSuccessCallBack = (resp, commit, state, getters) => { +const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { if (resp.notes && resp.notes.length) { const { notesById } = getters; @@ -200,10 +214,12 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { if (notesById[note.id]) { commit(types.UPDATE_NOTE, note); } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { - const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); + const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); if (discussion) { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); + } else if (note.type === constants.DIFF_NOTE) { + dispatch('fetchDiscussions', state.notesData.discussionsPath); } else { commit(types.ADD_NEW_NOTE, note); } @@ -218,13 +234,13 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { return resp; }; -export const poll = ({ commit, state, getters }) => { +export const poll = ({ commit, state, getters, dispatch }) => { eTagPoll = new Poll({ resource: service, method: 'poll', data: state, successCallback: resp => - resp.json().then(data => pollSuccessCallBack(data, commit, state, getters)), + resp.json().then(data => pollSuccessCallBack(data, commit, state, getters, dispatch)), errorCallback: () => Flash('Something went wrong while fetching latest comments.'), }); @@ -285,5 +301,13 @@ export const scrollToNoteIfNeeded = (context, el) => { } }; +export const fetchDiscussionDiffLines = ({ commit }, discussion) => + axios.get(discussion.truncatedDiffLinesPath).then(({ data }) => { + commit(types.SET_DISCUSSION_DIFF_LINES, { + discussionId: discussion.id, + diffLines: data.truncated_diff_lines, + }); + }); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js new file mode 100644 index 00000000000..fa4a1c56b20 --- /dev/null +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -0,0 +1,108 @@ +import { n__, s__, sprintf } from '~/locale'; +import { DESCRIPTION_TYPE } from '../constants'; + +/** + * Changes the description from a note, returns 'changed the description n number of times' + */ +export const changeDescriptionNote = (note, descriptionChangedTimes, timeDifferenceMinutes) => { + const descriptionNote = Object.assign({}, note); + + descriptionNote.note_html = sprintf( + s__(`MergeRequest| + %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}`), + { + paragraphStart: '<p dir="auto">', + paragraphEnd: '</p>', + descriptionChangedTimes, + timeDifferenceMinutes: n__('within %d minute ', 'within %d minutes ', timeDifferenceMinutes), + }, + false, + ); + + descriptionNote.times_updated = descriptionChangedTimes; + + return descriptionNote; +}; + +/** + * Checks the time difference between two notes from their 'created_at' dates + * returns an integer + */ + +export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => { + const descriptionNoteBegin = new Date(noteBeggining.created_at); + const descriptionNoteEnd = new Date(noteEnd.created_at); + const timeDifferenceMinutes = (descriptionNoteEnd - descriptionNoteBegin) / 1000 / 60; + + return Math.ceil(timeDifferenceMinutes); +}; + +/** + * Checks if a note is a system note and if the content is description + * + * @param {Object} note + * @returns {Boolean} + */ +export const isDescriptionSystemNote = note => note.system && note.note === DESCRIPTION_TYPE; + +/** + * Collapses the system notes of a description type, e.g. Changed the description, n minutes ago + * the notes will collapse as long as they happen no more than 10 minutes away from each away + * in between the notes can be anything, another type of system note + * (such as 'changed the weight') or a comment. + * + * @param {Array} notes + * @returns {Array} + */ +export const collapseSystemNotes = notes => { + let lastDescriptionSystemNote = null; + let lastDescriptionSystemNoteIndex = -1; + let descriptionChangedTimes = 1; + + return notes.slice(0).reduce((acc, currentNote) => { + const note = currentNote.notes[0]; + + if (isDescriptionSystemNote(note)) { + // is it the first one? + if (!lastDescriptionSystemNote) { + lastDescriptionSystemNote = note; + lastDescriptionSystemNoteIndex = acc.length; + } else if (lastDescriptionSystemNote) { + const timeDifferenceMinutes = getTimeDifferenceMinutes( + lastDescriptionSystemNote, + note, + ); + + // are they less than 10 minutes appart? + if (timeDifferenceMinutes > 10) { + // reset counter + descriptionChangedTimes = 1; + // update the previous system note + lastDescriptionSystemNote = note; + lastDescriptionSystemNoteIndex = acc.length; + } else { + // increase counter + descriptionChangedTimes += 1; + + // delete the previous one + acc.splice(lastDescriptionSystemNoteIndex, 1); + + // replace the text of the current system note with the collapsed note. + currentNote.notes.splice( + 0, + 1, + changeDescriptionNote(note, descriptionChangedTimes, timeDifferenceMinutes), + ); + + // update the previous system note index + lastDescriptionSystemNoteIndex = acc.length; + } + } + } + acc.push(currentNote); + return acc; + }, []); +}; + +// for babel-rewire +export default {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 787be6f4c99..a5518383d44 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,56 +1,91 @@ import _ from 'underscore'; +import * as constants from '../constants'; +import { collapseSystemNotes } from './collapse_utils'; + +export const discussions = state => collapseSystemNotes(state.discussions); -export const notes = state => state.notes; export const targetNoteHash = state => state.targetNoteHash; export const getNotesData = state => state.notesData; + +export const isNotesFetched = state => state.isNotesFetched; + export const getNotesDataByProp = state => prop => state.notesData[prop]; export const getNoteableData = state => state.noteableData; + export const getNoteableDataByProp = state => prop => state.noteableData[prop]; + export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; -export const getUserDataByProp = state => prop => - state.userData && state.userData[prop]; + +export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; export const notesById = state => - state.notes.reduce((acc, note) => { + state.discussions.reduce((acc, note) => { note.notes.every(n => Object.assign(acc, { [n.id]: n })); return acc; }, {}); +export const discussionsByLineCode = state => + state.discussions.reduce((acc, note) => { + if (note.diff_discussion && note.line_code && note.resolvable) { + // For context about line notes: there might be multiple notes with the same line code + const items = acc[note.line_code] || []; + items.push(note); + + Object.assign(acc, { [note.line_code]: items }); + } + return acc; + }, {}); + +export const noteableType = state => { + const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants; + + if (state.noteableData.noteableType === EPIC_NOTEABLE_TYPE) { + return EPIC_NOTEABLE_TYPE; + } + + return state.noteableData.merge_params ? MERGE_REQUEST_NOTEABLE_TYPE : ISSUE_NOTEABLE_TYPE; +}; + const reverseNotes = array => array.slice(0).reverse(); + const isLastNote = (note, state) => - !note.system && - state.userData && - note.author && - note.author.id === state.userData.id; + !note.system && state.userData && note.author && note.author.id === state.userData.id; export const getCurrentUserLastNote = state => - _.flatten( - reverseNotes(state.notes).map(note => reverseNotes(note.notes)), - ).find(el => isLastNote(el, state)); + _.flatten(reverseNotes(state.discussions).map(note => reverseNotes(note.notes))).find(el => + isLastNote(el, state), + ); export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes).find(el => isLastNote(el, state)); export const discussionCount = state => { - const discussions = state.notes.filter(n => !n.individual_note); + const filteredDiscussions = state.discussions.filter(n => !n.individual_note && n.resolvable); - return discussions.length; + return filteredDiscussions.length; }; export const unresolvedDiscussions = (state, getters) => { const resolvedMap = getters.resolvedDiscussionsById; - return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); + return state.discussions.filter(n => !n.individual_note && !resolvedMap[n.id]); +}; + +export const allDiscussions = (state, getters) => { + const resolved = getters.resolvedDiscussionsById; + const unresolved = getters.unresolvedDiscussions; + + return Object.values(resolved).concat(unresolved); }; export const resolvedDiscussionsById = state => { const map = {}; - state.notes.forEach(n => { + state.discussions.forEach(n => { if (n.notes) { const resolved = n.notes.every(note => note.resolved && !note.system); @@ -69,5 +104,15 @@ export const resolvedDiscussionCount = (state, getters) => { return Object.keys(resolvedMap).length; }; +export const discussionTabCounter = state => { + let all = []; + + state.discussions.forEach(discussion => { + all = all.concat(discussion.notes.filter(note => !note.system && !note.placeholder)); + }); + + return all.length; +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js index 9ed19bf171e..0f48b8880f4 100644 --- a/app/assets/javascripts/notes/stores/index.js +++ b/app/assets/javascripts/notes/stores/index.js @@ -3,24 +3,14 @@ import Vuex from 'vuex'; import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; +import module from './modules'; Vue.use(Vuex); -export default new Vuex.Store({ - state: { - notes: [], - targetNoteHash: null, - lastFetchedAt: null, - - // View layer - isToggleStateButtonLoading: false, - - // holds endpoints and permissions provided through haml - notesData: {}, - userData: {}, - noteableData: {}, - }, - actions, - getters, - mutations, -}); +export default () => + new Vuex.Store({ + state: module.state, + actions, + getters, + mutations, + }); diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js new file mode 100644 index 00000000000..b4cb9267e0f --- /dev/null +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -0,0 +1,27 @@ +import * as actions from '../actions'; +import * as getters from '../getters'; +import mutations from '../mutations'; + +export default { + state: { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + + // View layer + isToggleStateButtonLoading: false, + isNotesFetched: false, + + // holds endpoints and permissions provided through haml + notesData: { + markdownDocsPath: '', + }, + userData: {}, + noteableData: { + current_user: {}, + }, + }, + actions, + getters, + mutations, +}; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index b455e23ecde..a25098fbc06 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -1,11 +1,12 @@ export const ADD_NEW_NOTE = 'ADD_NEW_NOTE'; export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; export const DELETE_NOTE = 'DELETE_NOTE'; +export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION'; export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; export const SET_NOTES_DATA = 'SET_NOTES_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_INITIAL_DISCUSSIONS = 'SET_INITIAL_DISCUSSIONS'; export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT'; export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH'; export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; @@ -13,6 +14,8 @@ export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; +export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; +export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index c8edc06349f..e5e40ce07fa 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -6,8 +6,8 @@ import { isInMRPage } from '../../lib/utils/common_utils'; export default { [types.ADD_NEW_NOTE](state, note) { const { discussion_id, type } = note; - const [exists] = state.notes.filter(n => n.id === note.discussion_id); - const isDiscussion = type === constants.DISCUSSION_NOTE; + const [exists] = state.discussions.filter(n => n.id === note.discussion_id); + const isDiscussion = type === constants.DISCUSSION_NOTE || type === constants.DIFF_NOTE; if (!exists) { const noteData = { @@ -25,42 +25,44 @@ export default { noteData.resolve_with_issue_path = note.resolve_with_issue_path; } - state.notes.push(noteData); - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + state.discussions.push(noteData); } }, [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) { - const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj) { noteObj.notes.push(note); - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, [types.DELETE_NOTE](state, note) { - const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj.individual_note) { - state.notes.splice(state.notes.indexOf(noteObj), 1); + state.discussions.splice(state.discussions.indexOf(noteObj), 1); } else { const comment = utils.findNoteObjectById(noteObj.notes, note.id); noteObj.notes.splice(noteObj.notes.indexOf(comment), 1); if (!noteObj.notes.length) { - state.notes.splice(state.notes.indexOf(noteObj), 1); + state.discussions.splice(state.discussions.indexOf(noteObj), 1); } } + }, + + [types.EXPAND_DISCUSSION](state, { discussionId }) { + const discussion = utils.findNoteObjectById(state.discussions, discussionId); - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + discussion.expanded = true; }, [types.REMOVE_PLACEHOLDER_NOTES](state) { - const { notes } = state; + const { discussions } = state; - for (let i = notes.length - 1; i >= 0; i -= 1) { - const note = notes[i]; + for (let i = discussions.length - 1; i >= 0; i -= 1) { + const note = discussions[i]; const children = note.notes; if (children.length && !note.individual_note) { @@ -72,7 +74,7 @@ export default { } } else if (note.isPlaceholderNote) { // remove placeholders from state root - notes.splice(i, 1); + discussions.splice(i, 1); } } }, @@ -88,29 +90,29 @@ export default { [types.SET_USER_DATA](state, data) { Object.assign(state, { userData: data }); }, - [types.SET_INITIAL_NOTES](state, notesData) { - const notes = []; + [types.SET_INITIAL_DISCUSSIONS](state, discussionsData) { + const discussions = []; - notesData.forEach(note => { + discussionsData.forEach(discussion => { // To support legacy notes, should be very rare case. - if (note.individual_note && note.notes.length > 1) { - note.notes.forEach(n => { - notes.push({ - ...note, + if (discussion.individual_note && discussion.notes.length > 1) { + discussion.notes.forEach(n => { + discussions.push({ + ...discussion, notes: [n], // override notes array to only have one item to mimick individual_note }); }); } else { - const oldNote = utils.findNoteObjectById(state.notes, note.id); + const oldNote = utils.findNoteObjectById(state.discussions, discussion.id); - notes.push({ - ...note, - expanded: oldNote ? oldNote.expanded : note.expanded, + discussions.push({ + ...discussion, + expanded: oldNote ? oldNote.expanded : discussion.expanded, }); } }); - Object.assign(state, { notes }); + Object.assign(state, { discussions }); }, [types.SET_LAST_FETCHED_AT](state, fetchedAt) { @@ -122,17 +124,17 @@ export default { }, [types.SHOW_PLACEHOLDER_NOTE](state, data) { - let notesArr = state.notes; - if (data.replyId) { - notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes; + let notesArr = state.discussions; + + const existingDiscussion = utils.findNoteObjectById(notesArr, data.replyId); + if (existingDiscussion) { + notesArr = existingDiscussion.notes; } notesArr.push({ individual_note: true, isPlaceholderNote: true, - placeholderType: data.isSystemNote - ? constants.SYSTEM_NOTE - : constants.NOTE, + placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, notes: [ { body: data.noteBody, @@ -151,28 +153,23 @@ export default { if (hasEmojiAwardedByCurrentUser.length) { // If current user has awarded this emoji, remove it. - note.award_emoji.splice( - note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), - 1, - ); + note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1); } else { note.award_emoji.push({ name: awardName, user: { id, name, username }, }); } - - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.TOGGLE_DISCUSSION](state, { discussionId }) { - const discussion = utils.findNoteObjectById(state.notes, discussionId); + const discussion = utils.findNoteObjectById(state.discussions, discussionId); discussion.expanded = !discussion.expanded; }, [types.UPDATE_NOTE](state, note) { - const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); if (noteObj.individual_note) { noteObj.notes.splice(0, 1, note); @@ -180,24 +177,20 @@ export default { const comment = utils.findNoteObjectById(noteObj.notes, note.id); noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); } - - // document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.UPDATE_DISCUSSION](state, noteData) { const note = noteData; let index = 0; - state.notes.forEach((n, i) => { + state.discussions.forEach((n, i) => { if (n.id === note.id) { index = i; } }); note.expanded = true; // override expand flag to prevent collapse - state.notes.splice(index, 1, note); - - document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + state.discussions.splice(index, 1, note); }, [types.CLOSE_ISSUE](state) { @@ -211,4 +204,19 @@ export default { [types.TOGGLE_STATE_BUTTON_LOADING](state, value) { Object.assign(state, { isToggleStateButtonLoading: value }); }, + + [types.SET_NOTES_FETCHED_STATE](state, value) { + Object.assign(state, { isNotesFetched: value }); + }, + + [types.SET_DISCUSSION_DIFF_LINES](state, { discussionId, diffLines }) { + const discussion = utils.findNoteObjectById(state.discussions, discussionId); + const index = state.discussions.indexOf(discussion); + + const discussionWithDiffLines = Object.assign({}, discussion, { + truncated_diff_lines: diffLines, + }); + + state.discussions.splice(index, 1, discussionWithDiffLines); + }, }; diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index ba1d8e4d8db..bc84666779e 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -40,8 +40,8 @@ <gl-modal id="stop-jobs-modal" :header-title-text="s__('AdminArea|Stop all jobs?')" - footer-primary-button-variant="danger" :footer-primary-button-text="s__('AdminArea|Stop jobs')" + footer-primary-button-variant="danger" @submit="onSubmit" > {{ text }} diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue index 343c65edb37..ff66d3a8ac4 100644 --- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -83,9 +83,9 @@ id="delete-project-modal" :title="title" :text="text" - kind="danger" :primary-button-label="primaryButtonLabel" :submit-disabled="!canSubmit" + kind="danger" @submit="onSubmit" @cancel="onCancel" > @@ -107,15 +107,15 @@ value="delete" /> <input + :value="csrfToken" type="hidden" name="authenticity_token" - :value="csrfToken" /> <input + v-model="enteredProjectName" name="projectName" class="form-control" type="text" - v-model="enteredProjectName" aria-labelledby="input-label" autocomplete="off" /> diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index 9ce176744ba..d6aa4bb95d2 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -96,7 +96,7 @@ this.enteredUsername = ''; }, onSecondaryAction() { - const form = this.$refs.form; + const { form } = this.$refs; form.action = this.blockUserUrl; this.$refs.method.value = 'put'; @@ -116,10 +116,10 @@ id="delete-user-modal" :title="title" :text="text" - kind="danger" :primary-button-label="primaryButtonLabel" :secondary-button-label="secondaryButtonLabel" :submit-disabled="!canSubmit" + kind="danger" @submit="onSubmit" @cancel="onCancel" > @@ -141,15 +141,15 @@ value="delete" /> <input + :value="csrfToken" type="hidden" name="authenticity_token" - :value="csrfToken" /> <input + v-model="enteredUsername" type="text" name="username" class="form-control" - v-model="enteredUsername" aria-labelledby="input-label" autocomplete="off" /> @@ -160,11 +160,11 @@ slot-scope="props" > <button + :disabled="!canSubmit" type="button" class="btn js-secondary-button btn-warning" - :disabled="!canSubmit" - @click="onSecondaryAction" data-dismiss="modal" + @click="onSecondaryAction" > {{ secondaryButtonLabel }} </button> diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index c334eaa90f8..ff19b9a9c30 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -1,4 +1,4 @@ -/* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */ +/* eslint-disable class-methods-use-this, no-unneeded-ternary */ import $ from 'jquery'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -61,7 +61,7 @@ export default class Todos { e.stopPropagation(); e.preventDefault(); - const target = e.target; + const { target } = e; target.setAttribute('disabled', true); target.classList.add('disabled'); diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue index 16f792d635a..4061c11ba8f 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue @@ -96,8 +96,8 @@ Once deleted, it cannot be undone or recovered.`), id="delete-milestone-modal" :title="title" :text="text" - kind="danger" :primary-button-label="s__('Milestones|Delete milestone')" + kind="danger" @submit="onSubmit"> <template diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index 2bda2aeb3a1..2c683a39f42 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -53,8 +53,8 @@ <template> <gl-modal id="promote-milestone-modal" - footer-primary-button-variant="warning" :footer-primary-button-text="s__('Milestones|Promote Milestone')" + footer-primary-button-variant="warning" @submit="onSubmit" > <template diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js index 653e2502d01..6c1788dc160 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js +++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, camelcase, one-var-declaration-per-line, quotes, no-param-reassign, quote-props, comma-dangle, prefer-template, max-len, no-return-assign, no-shadow */ +/* eslint-disable func-names, wrap-iife, no-var, one-var, camelcase, one-var-declaration-per-line, quotes, no-param-reassign, quote-props, comma-dangle, prefer-template, max-len, no-return-assign */ import $ from 'jquery'; import _ from 'underscore'; @@ -36,7 +36,9 @@ export default (function() { var author_graph, author_header; author_header = _this.create_author_header(d); $(".contributors-list").append(author_header); - _this.authors[d.author_name] = author_graph = new ContributorsAuthorGraph(d.dates); + + author_graph = new ContributorsAuthorGraph(d.dates); + _this.authors[d.author_name] = author_graph; return author_graph.draw(); }; })(this)); @@ -78,10 +80,11 @@ export default (function() { }; ContributorsStatGraph.prototype.redraw_authors = function() { - var author_commits, x_domain; $("ol").html(""); - x_domain = ContributorsGraph.prototype.x_domain; - author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field, x_domain); + + const { x_domain } = ContributorsGraph.prototype; + const author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field, x_domain); + return _.each(author_commits, (function(_this) { return function(d) { _this.redraw_author_commit_info(d); @@ -100,7 +103,7 @@ export default (function() { }; ContributorsStatGraph.prototype.change_date_header = function() { - const x_domain = ContributorsGraph.prototype.x_domain; + const { x_domain } = ContributorsGraph.prototype; const formattedDateRange = sprintf( s__('ContributorsPage|%{startDate} – %{endDate}'), { diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js index 5316d3e9f3c..a02ec9e5f00 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-rest-params, max-len, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */ +/* eslint-disable func-names, max-len, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */ import $ from 'jquery'; import _ from 'underscore'; diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js index 77135ad1f0e..d12249bf612 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js +++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, object-shorthand, no-var, one-var, camelcase, one-var-declaration-per-line, comma-dangle, no-param-reassign, no-return-assign, quotes, prefer-arrow-callback, wrap-iife, consistent-return, no-unused-vars, max-len, no-cond-assign, no-else-return, max-len */ +/* eslint-disable func-names, object-shorthand, no-var, one-var, camelcase, one-var-declaration-per-line, comma-dangle, no-param-reassign, no-return-assign, quotes, prefer-arrow-callback, wrap-iife, consistent-return, no-unused-vars, max-len, no-cond-assign, no-else-return, max-len */ import _ from 'underscore'; export default { @@ -111,10 +111,15 @@ export default { parse_log_entry: function(log_entry, field, date_range) { var parsed_entry; parsed_entry = {}; + parsed_entry.author_name = log_entry.author_name; parsed_entry.author_email = log_entry.author_email; parsed_entry.dates = {}; - parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0; + + parsed_entry.commits = 0; + parsed_entry.additions = 0; + parsed_entry.deletions = 0; + _.each(_.omit(log_entry, 'author_name', 'author_email'), (function(_this) { return function(value, key) { if (_this.in_range(value.date, date_range)) { diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js index 82143fa875a..56ab3fcdfcb 100644 --- a/app/assets/javascripts/pages/projects/init_blob.js +++ b/app/assets/javascripts/pages/projects/init_blob.js @@ -8,7 +8,8 @@ import initBlobBundle from '~/blob_edit/blob_bundle'; export default () => { new LineHighlighter(); // eslint-disable-line no-new - new BlobLinePermalinkUpdater( // eslint-disable-line no-new + // eslint-disable-next-line no-new + new BlobLinePermalinkUpdater( document.querySelector('#blob-content-holder'), '.diff-line-num[data-line-number]', document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'), @@ -19,12 +20,13 @@ export default () => { new ShortcutsNavigation(); // eslint-disable-line no-new - new ShortcutsBlob({ // eslint-disable-line no-new + // eslint-disable-next-line no-new + new ShortcutsBlob({ skipResetBindings: true, fileBlobPermalinkUrl, }); - new BlobForkSuggestion({ // eslint-disable-line no-new + new BlobForkSuggestion({ openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'), forkButtons: document.querySelectorAll('.js-fork-suggestion-button'), cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'), diff --git a/app/assets/javascripts/pages/projects/init_form.js b/app/assets/javascripts/pages/projects/init_form.js index 0b6c5c1d30b..9f20a3e4e46 100644 --- a/app/assets/javascripts/pages/projects/init_form.js +++ b/app/assets/javascripts/pages/projects/init_form.js @@ -3,5 +3,5 @@ import GLForm from '~/gl_form'; export default function ($formEl) { new ZenMode(); // eslint-disable-line no-new - new GLForm($formEl, true); // eslint-disable-line no-new + new GLForm($formEl); // eslint-disable-line no-new } diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 14fddbc9a05..b2b8e5d2300 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -10,7 +10,7 @@ import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; export default () => { new ShortcutsNavigation(); - new GLForm($('.issue-form'), true); + new GLForm($('.issue-form')); new IssuableForm($('.issue-form')); new LabelsSelect(); new MilestoneSelect(); diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index ad6df51bb7a..5d2247f6c6d 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -71,8 +71,8 @@ <template> <gl-modal id="promote-label-modal" - footer-primary-button-variant="warning" :footer-primary-button-text="s__('Labels|Promote Label')" + footer-primary-button-variant="warning" @submit="onSubmit" > <div diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 406fc32f9a2..3a3c21f2202 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -12,7 +12,7 @@ import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; export default () => { new Diff(); new ShortcutsNavigation(); - new GLForm($('.merge-request-form'), true); + new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); new LabelsSelect(); new MilestoneSelect(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 28d8761b502..26ead75cec4 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -1,30 +1,15 @@ -import MergeRequest from '~/merge_request'; import ZenMode from '~/zen_mode'; -import initNotes from '~/init_notes'; import initIssuableSidebar from '~/init_issuable_sidebar'; -import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import ShortcutsIssuable from '~/shortcuts_issuable'; -import Diff from '~/diff'; import { handleLocationHash } from '~/lib/utils/common_utils'; import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initWidget from '../../../vue_merge_request_widget'; -export default function () { - new Diff(); // eslint-disable-line no-new +export default function() { new ZenMode(); // eslint-disable-line no-new - initIssuableSidebar(); - initNotes(); - initDiffNotes(); initPipelines(); - - const mrShowNode = document.querySelector('.merge-request'); - - window.mergeRequest = new MergeRequest({ - action: mrShowNode.dataset.mrAction, - }); - new ShortcutsIssuable(true); // eslint-disable-line no-new handleLocationHash(); howToMerge(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index e5b2827b50c..f61f4db78d5 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,4 +1,3 @@ -import { hasVueMRDiscussionsCookie } from '~/lib/utils/common_utils'; import initMrNotes from '~/mr_notes'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initShow from '../init_merge_request_show'; @@ -6,8 +5,5 @@ import initShow from '../init_merge_request_show'; document.addEventListener('DOMContentLoaded', () => { initShow(); initSidebarBundle(); - - if (hasVueMRDiscussionsCookie()) { - initMrNotes(); - } + initMrNotes(); }); diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index aa50dd4bb25..77368c47451 100644 --- a/app/assets/javascripts/pages/projects/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */ +/* eslint-disable func-names, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */ import $ from 'jquery'; import BranchGraph from '../../../network/branch_graph'; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 2d18fa2044b..d0613804067 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -65,11 +65,11 @@ <div class="cron-preset-radio-input"> <input id="custom" - class="label-light" - type="radio" :name="inputNameAttribute" :value="cronInterval" :checked="isEditable" + class="label-light" + type="radio" @click="toggleCustomInput(true)" /> @@ -90,11 +90,11 @@ <div class="cron-preset-radio-input"> <input id="every-day" - class="label-light" - type="radio" v-model="cronInterval" :name="inputNameAttribute" :value="cronIntervalPresets.everyDay" + class="label-light" + type="radio" @click="toggleCustomInput(false)" /> @@ -109,11 +109,11 @@ <div class="cron-preset-radio-input"> <input id="every-week" - class="label-light" - type="radio" v-model="cronInterval" :name="inputNameAttribute" :value="cronIntervalPresets.everyWeek" + class="label-light" + type="radio" @click="toggleCustomInput(false)" /> @@ -128,11 +128,11 @@ <div class="cron-preset-radio-input"> <input id="every-month" - class="label-light" - type="radio" v-model="cronInterval" :name="inputNameAttribute" :value="cronIntervalPresets.everyMonth" + class="label-light" + type="radio" @click="toggleCustomInput(false)" /> @@ -147,13 +147,13 @@ <div class="cron-interval-input-wrapper"> <input id="schedule_cron" - class="form-control inline cron-interval-input" - type="text" :placeholder="__('Define a custom pattern with cron syntax')" - required="true" v-model="cronInterval" :name="inputNameAttribute" :disabled="!isEditable" + class="form-control inline cron-interval-input" + type="text" + required="true" /> </div> </div> diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index c1e3425ec75..a853624e944 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -1,4 +1,5 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, consistent-return, no-new, prefer-arrow-callback, no-return-assign, one-var, one-var-declaration-per-line, object-shorthand, no-else-return, newline-per-chained-call, no-shadow, vars-on-top, prefer-template, max-len */ +/* eslint-disable func-names, no-var, no-return-assign, one-var, + one-var-declaration-per-line, object-shorthand, vars-on-top */ import $ from 'jquery'; import Cookies from 'js-cookie'; diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 6c2a785c0af..1faa59fb45b 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -22,4 +22,18 @@ document.addEventListener('DOMContentLoaded', () => { errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), saveEndpoint: variableListEl.dataset.saveEndpoint, }); + + // hide extra auto devops settings based on data-attributes + const autoDevOpsSettings = document.querySelector('.js-auto-devops-settings'); + const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); + + autoDevOpsSettings.addEventListener('click', event => { + const { target } = event; + if (target.classList.contains('js-toggle-extra-settings')) { + autoDevOpsExtraSettings.classList.toggle( + 'hidden', + !!(target.dataset && target.dataset.hideExtraSettings), + ); + } + }); }); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index a5c17ab322c..a52861c9efa 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -13,7 +13,7 @@ export default () => { new ProtectedTagEditList(); initDeployKeys(); initSettingsPanels(); - new ProtectedBranchCreate(); // eslint-disable-line no-new - new ProtectedBranchEditList(); // eslint-disable-line no-new + new ProtectedBranchCreate(); + new ProtectedBranchEditList(); new DueDateSelectors(); }; diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index 9b13b2a524f..06101290f6c 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -72,25 +72,25 @@ <template> <div - class="project-feature-controls" :data-for="name" + class="project-feature-controls" > <input v-if="name" - type="hidden" :name="name" :value="value" + type="hidden" /> <project-feature-toggle :value="featureEnabled" - @change="toggleFeature" :disabled-input="disabledInput" + @change="toggleFeature" /> <div class="select-wrapper"> <select + :disabled="displaySelectInput" class="form-control project-repo-select select-control" @change="selectOption" - :disabled="displaySelectInput" > <option v-for="[optionValue, optionName] in displayOptions" diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 06b0ab184ed..ae88b765abf 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -175,16 +175,16 @@ <div> <div class="project-visibility-setting"> <project-setting-row - label="Project visibility" :help-path="visibilityHelpPath" + label="Project visibility" > <div class="project-feature-controls"> <div class="select-wrapper"> <select - name="project[visibility_level]" v-model="visibilityLevel" - class="form-control select-control" :disabled="!canChangeVisibilityLevel" + name="project[visibility_level]" + class="form-control select-control" > <option :value="visibilityOptions.PRIVATE" @@ -219,30 +219,30 @@ class="request-access" > <input + :value="requestAccessEnabled" type="hidden" name="project[request_access_enabled]" - :value="requestAccessEnabled" /> <input - type="checkbox" v-model="requestAccessEnabled" + type="checkbox" /> Allow users to request access </label> </project-setting-row> </div> <div - class="project-feature-settings" :class="{ 'highlight-changes': highlightChangesClass }" + class="project-feature-settings" > <project-setting-row label="Issues" help-text="Lightweight issue tracking system for this project" > <project-feature-setting - name="project[project_feature_attributes][issues_access_level]" :options="featureAccessLevelOptions" v-model="issuesAccessLevel" + name="project[project_feature_attributes][issues_access_level]" /> </project-setting-row> <project-setting-row @@ -250,9 +250,9 @@ help-text="View and edit files in this project" > <project-feature-setting - name="project[project_feature_attributes][repository_access_level]" :options="featureAccessLevelOptions" v-model="repositoryAccessLevel" + name="project[project_feature_attributes][repository_access_level]" /> </project-setting-row> <div class="project-feature-setting-group"> @@ -261,10 +261,10 @@ help-text="Submit changes to be merged upstream" > <project-feature-setting - name="project[project_feature_attributes][merge_requests_access_level]" :options="repoFeatureAccessLevelOptions" v-model="mergeRequestsAccessLevel" :disabled-input="!repositoryEnabled" + name="project[project_feature_attributes][merge_requests_access_level]" /> </project-setting-row> <project-setting-row @@ -272,34 +272,34 @@ help-text="Build, test, and deploy your changes" > <project-feature-setting - name="project[project_feature_attributes][builds_access_level]" :options="repoFeatureAccessLevelOptions" v-model="buildsAccessLevel" :disabled-input="!repositoryEnabled" + name="project[project_feature_attributes][builds_access_level]" /> </project-setting-row> <project-setting-row v-if="registryAvailable" - label="Container registry" :help-path="registryHelpPath" + label="Container registry" help-text="Every project can have its own space to store its Docker images" > <project-feature-toggle - name="project[container_registry_enabled]" v-model="containerRegistryEnabled" :disabled-input="!repositoryEnabled" + name="project[container_registry_enabled]" /> </project-setting-row> <project-setting-row v-if="lfsAvailable" - label="Git Large File Storage" :help-path="lfsHelpPath" + label="Git Large File Storage" help-text="Manages large files such as audio, video, and graphics files" > <project-feature-toggle - name="project[lfs_enabled]" v-model="lfsEnabled" :disabled-input="!repositoryEnabled" + name="project[lfs_enabled]" /> </project-setting-row> </div> @@ -308,9 +308,9 @@ help-text="Pages for project documentation" > <project-feature-setting - name="project[project_feature_attributes][wiki_access_level]" :options="featureAccessLevelOptions" v-model="wikiAccessLevel" + name="project[project_feature_attributes][wiki_access_level]" /> </project-setting-row> <project-setting-row @@ -318,9 +318,9 @@ help-text="Share code pastes with others out of Git repository" > <project-feature-setting - name="project[project_feature_attributes][snippets_access_level]" :options="featureAccessLevelOptions" v-model="snippetsAccessLevel" + name="project[project_feature_attributes][snippets_access_level]" /> </project-setting-row> </div> diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js index 8d0edf7e06c..b3158f7e939 100644 --- a/app/assets/javascripts/pages/projects/tags/new/index.js +++ b/app/assets/javascripts/pages/projects/tags/new/index.js @@ -5,6 +5,6 @@ import GLForm from '../../../../gl_form'; document.addEventListener('DOMContentLoaded', () => { new ZenMode(); // eslint-disable-line no-new - new GLForm($('.tag-form'), true); // eslint-disable-line no-new + new GLForm($('.tag-form')); // eslint-disable-line no-new new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue index 5765eed4d45..0289209ff1e 100644 --- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue @@ -50,8 +50,8 @@ export default { <gl-modal id="delete-wiki-modal" :header-title-text="title" - footer-primary-button-variant="danger" :footer-primary-button-text="s__('WikiPageConfirmDelete|Delete page')" + footer-primary-button-variant="danger" @submit="onSubmit" > {{ message }} @@ -68,9 +68,9 @@ export default { value="delete" /> <input + :value="csrfToken" type="hidden" name="authenticity_token" - :value="csrfToken" /> </form> </gl-modal> diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js index 0295653cb29..0a0fe3fc137 100644 --- a/app/assets/javascripts/pages/projects/wikis/index.js +++ b/app/assets/javascripts/pages/projects/wikis/index.js @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { new Wikis(); // eslint-disable-line no-new new ShortcutsWiki(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new - new GLForm($('.wiki-form'), true); // eslint-disable-line no-new + new GLForm($('.wiki-form')); // eslint-disable-line no-new const deleteWikiButton = document.getElementById('delete-wiki-button'); diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index dcd0b9a76ce..d3e8dbf4000 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -48,7 +48,7 @@ export default class Wikis { static sidebarCanCollapse() { const bootstrapBreakpoint = bp.getBreakpointSize(); - return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; + return bootstrapBreakpoint === 'xs'; } renderSidebar() { diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 2e1fe78b3fa..e3e0ab91993 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -105,7 +105,7 @@ export default class Search { getProjectsData(term) { return new Promise((resolve) => { if (this.groupId) { - Api.groupProjects(this.groupId, term, resolve); + Api.groupProjects(this.groupId, term, {}, resolve); } else { Api.projects(term, { order_by: 'id', diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 80a7114f94d..07f32210d93 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -6,7 +6,8 @@ import OAuthRememberMe from './oauth_remember_me'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new new SigninTabsMemoizer(); // eslint-disable-line no-new - new OAuthRememberMe({ // eslint-disable-line no-new + + new OAuthRememberMe({ container: $('.omniauth-container'), }).bindEvents(); }); diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js index 18c7b21cf8c..761618109a4 100644 --- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js +++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js @@ -17,7 +17,6 @@ export default class OAuthRememberMe { $('#remember_me', this.container).on('click', this.toggleRememberMe); } - // eslint-disable-next-line class-methods-use-this toggleRememberMe(event) { const rememberMe = $(event.target).is(':checked'); diff --git a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js index d321892d2d2..1e7c29aefaa 100644 --- a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js +++ b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js @@ -37,6 +37,11 @@ export default class SigninTabsMemoizer { const tab = document.querySelector(`${this.tabSelector} a[href="${anchorName}"]`); if (tab) { tab.click(); + } else { + const firstTab = document.querySelector(`${this.tabSelector} a`); + if (firstTab) { + firstTab.click(); + } } } } diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index 825de01b5a2..97cf1aeaadc 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, consistent-return, class-methods-use-this, arrow-parens, no-param-reassign, max-len */ +/* eslint-disable comma-dangle, consistent-return, class-methods-use-this */ import $ from 'jquery'; import _ from 'underscore'; @@ -62,13 +62,13 @@ export default class UsernameValidator { return this.setPendingState(); } - if (!this.state.available) { - return this.setUnavailableState(); - } - if (!this.state.valid) { return this.setInvalidState(); } + + if (!this.state.available) { + return this.setUnavailableState(); + } } interceptInvalid(event) { @@ -89,7 +89,6 @@ export default class UsernameValidator { setAvailabilityState(usernameTaken) { if (usernameTaken) { - this.state.valid = false; this.state.available = false; } else { this.state.available = true; diff --git a/app/assets/javascripts/pages/snippets/form.js b/app/assets/javascripts/pages/snippets/form.js index 72d05da1069..f369c7ef9a6 100644 --- a/app/assets/javascripts/pages/snippets/form.js +++ b/app/assets/javascripts/pages/snippets/form.js @@ -3,6 +3,14 @@ import GLForm from '~/gl_form'; import ZenMode from '~/zen_mode'; export default () => { - new GLForm($('.snippet-form'), false); // eslint-disable-line no-new + // eslint-disable-next-line no-new + new GLForm($('.snippet-form'), { + members: false, + issues: false, + mergeRequests: false, + epics: false, + milestones: false, + labels: false, + }); new ZenMode(); // eslint-disable-line no-new }; diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 50d042fef29..9892a039941 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import _ from 'underscore'; import { scaleLinear, scaleThreshold } from 'd3-scale'; import { select } from 'd3-selection'; +import dateFormat from 'dateformat'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; @@ -26,7 +27,7 @@ function getSystemDate(systemUtcOffsetSeconds) { function formatTooltipText({ date, count }) { const dateObject = new Date(date); const dateDayName = getDayName(dateObject); - const dateText = dateObject.format('mmm d, yyyy'); + const dateText = dateFormat(dateObject, 'mmm d, yyyy'); let contribText = 'No contributions'; if (count > 0) { @@ -84,7 +85,7 @@ export default class ActivityCalendar { date.setDate(date.getDate() + i); const day = date.getDay(); - const count = timestamps[date.format('yyyy-mm-dd')] || 0; + const count = timestamps[dateFormat(date, 'yyyy-mm-dd')] || 0; // Create a new group array if this is the first day of the week // or if is first object diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 9404b06615e..a2ca03536f2 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -180,14 +180,14 @@ export default class UserTabs { } toggleLoading(status) { - return this.$parentEl.find('.loading-status .loading').toggleClass('hidden', !status); + return this.$parentEl.find('.loading-status .loading').toggleClass('hide', !status); } setCurrentAction(source) { let newState = source; newState = newState.replace(/\/+$/, ''); newState += this.windowLocation.search + this.windowLocation.hash; - history.replaceState( + window.history.replaceState( { url: newState, }, diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index 00f32d9de78..2f480ecdc69 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -56,8 +56,8 @@ <template> <div - class="pdf-viewer" - v-if="hasPDF"> + v-if="hasPDF" + class="pdf-viewer"> <page v-for="(page, index) in pages" :key="index" diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue index fcba819beba..9f06833d560 100644 --- a/app/assets/javascripts/pdf/page/index.vue +++ b/app/assets/javascripts/pdf/page/index.vue @@ -43,9 +43,9 @@ <template> <canvas - class="pdf-page" ref="canvas" :data-page="number" + class="pdf-page" > </canvas> </template> diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index db8a0055acd..dc7d6d29b8f 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -39,9 +39,9 @@ export default { </script> <template> <div + v-if="currentRequest.details" :id="`peek-view-${metric}`" class="view" - v-if="currentRequest.details" > <button :data-target="`#modal-peek-${metric}-details`" @@ -56,6 +56,7 @@ export default { <gl-modal :id="`modal-peek-${metric}-details`" :header-title-text="header" + modal-size="xl" class="performance-bar-modal" > <table diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 8ffaa52d9e8..b76965f280b 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -113,7 +113,7 @@ export default { > <div v-if="currentRequest" - class="container-fluid container-limited" + class="d-flex container-fluid container-limited" > <div id="peek-view-host" @@ -179,6 +179,7 @@ export default { v-if="currentRequest" :current-request="currentRequest" :requests="requests" + class="ml-auto" @change-current-request="changeCurrentRequest" /> </div> diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index dd9578a6c7f..ad74f7b38f9 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -35,10 +35,7 @@ export default { }; </script> <template> - <div - id="peek-request-selector" - class="float-right" - > + <div id="peek-request-selector"> <select v-model="currentRequestId"> <option v-for="request in requests" diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/blank_state.vue index f3219b8291c..34360105176 100644 --- a/app/assets/javascripts/pipelines/components/blank_state.vue +++ b/app/assets/javascripts/pipelines/components/blank_state.vue @@ -1,18 +1,18 @@ <script> - export default { - name: 'PipelinesSvgState', - props: { - svgPath: { - type: String, - required: true, - }, +export default { + name: 'PipelinesSvgState', + props: { + svgPath: { + type: String, + required: true, + }, - message: { - type: String, - required: true, - }, + message: { + type: String, + required: true, }, - }; + }, +}; </script> <template> diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue index 50c27bed9fd..c5a45afc634 100644 --- a/app/assets/javascripts/pipelines/components/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/empty_state.vue @@ -1,21 +1,21 @@ <script> - export default { - name: 'PipelinesEmptyState', - props: { - helpPagePath: { - type: String, - required: true, - }, - emptyStateSvgPath: { - type: String, - required: true, - }, - canSetCi: { - type: Boolean, - required: true, - }, +export default { + name: 'PipelinesEmptyState', + props: { + helpPagePath: { + type: String, + required: true, }, - }; + emptyStateSvgPath: { + type: String, + required: true, + }, + canSetCi: { + type: Boolean, + required: true, + }, + }, +}; </script> <template> <div class="row empty-state js-empty-state"> diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 82b4ce083fb..b82e28a0735 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -41,7 +41,6 @@ export default { type: String, required: true, }, - }, data() { return { @@ -67,7 +66,8 @@ export default { this.isDisabled = true; - axios.post(`${this.link}.json`) + axios + .post(`${this.link}.json`) .then(() => { this.isDisabled = false; this.$emit('pipelineActionRequestComplete'); @@ -83,15 +83,16 @@ export default { </script> <template> <button - type="button" - @click="onClickAction" v-tooltip :title="tooltipText" + :class="cssClass" + :disabled="isDisabled" + type="button" class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" - :class="cssClass" data-container="body" - :disabled="isDisabled" + data-boundary="viewport" + @click="onClickAction" > <icon :name="actionIcon"/> </button> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue index e64afc94ef9..c32dc83da8e 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -82,12 +82,13 @@ export default { <div class="ci-job-dropdown-container dropdown dropright"> <button v-tooltip + :title="tooltipText" type="button" data-toggle="dropdown" data-container="body" data-boundary="viewport" + data-display="static" class="dropdown-menu-toggle build-content" - :title="tooltipText" > <job-name-component @@ -108,6 +109,7 @@ export default { :key="i" > <job-component + :dropdown-length="job.size" :job="item" css-class-job-name="mini-pipeline-graph-dropdown-item" @pipelineActionRequestComplete="pipelineActionRequestComplete" diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index dc16d395bcb..8af984ef91a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -46,6 +46,11 @@ export default { required: false, default: '', }, + dropdownLength: { + type: Number, + required: false, + default: Infinity, + }, }, computed: { status() { @@ -70,6 +75,10 @@ export default { return textBuilder.join(' '); }, + tooltipBoundary() { + return this.dropdownLength < 5 ? 'viewport' : null; + }, + /** * Verifies if the provided job has an action path * @@ -94,9 +103,9 @@ export default { :href="status.details_path" :title="tooltipText" :class="cssClassJobName" + :data-boundary="tooltipBoundary" data-container="body" data-html="true" - data-boundary="viewport" class="js-pipeline-graph-job-link" > @@ -107,11 +116,11 @@ export default { </a> <div - v-else v-tooltip - class="js-job-component-tooltip non-details-job-component" + v-else :title="tooltipText" :class="cssClassJobName" + class="js-job-component-tooltip non-details-job-component" data-html="true" data-container="body" > diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index 14f4964a406..6fdbcc1e049 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -1,28 +1,28 @@ <script> - import ciIcon from '../../../vue_shared/components/ci_icon.vue'; +import ciIcon from '../../../vue_shared/components/ci_icon.vue'; - /** - * Component that renders both the CI icon status and the job name. - * Used in - * - Badge component - * - Dropdown badge components - */ - export default { - components: { - ciIcon, +/** + * Component that renders both the CI icon status and the job name. + * Used in + * - Badge component + * - Dropdown badge components + */ +export default { + components: { + ciIcon, + }, + props: { + name: { + type: String, + required: true, }, - props: { - name: { - type: String, - required: true, - }, - status: { - type: Object, - required: true, - }, + status: { + type: Object, + required: true, }, - }; + }, +}; </script> <template> <span class="ci-job-name-component"> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index f32368947e8..2c728582b7c 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -52,8 +52,8 @@ export default { </script> <template> <li - class="stage-column" - :class="stageConnectorClass"> + :class="stageConnectorClass" + class="stage-column"> <div class="stage-name"> {{ title }} </div> @@ -62,9 +62,9 @@ export default { <li v-for="(job, index) in jobs" :key="job.id" - class="build" :class="buildConnnectorClass(index)" :id="jobId(job)" + class="build" > <div class="curve"></div> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index e08c2092680..001eaeaa065 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,92 +1,92 @@ <script> - import ciHeader from '../../vue_shared/components/header_ci_component.vue'; - import eventHub from '../event_hub'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - export default { - name: 'PipelineHeaderSection', - components: { - ciHeader, - loadingIcon, +export default { + name: 'PipelineHeaderSection', + components: { + ciHeader, + loadingIcon, + }, + props: { + pipeline: { + type: Object, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, - }, - data() { - return { - actions: this.getActions(), - }; + isLoading: { + type: Boolean, + required: true, }, + }, + data() { + return { + actions: this.getActions(), + }; + }, - computed: { - status() { - return this.pipeline.details && this.pipeline.details.status; - }, - shouldRenderContent() { - return !this.isLoading && Object.keys(this.pipeline).length; - }, + computed: { + status() { + return this.pipeline.details && this.pipeline.details.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.pipeline).length; }, + }, - watch: { - pipeline() { - this.actions = this.getActions(); - }, + watch: { + pipeline() { + this.actions = this.getActions(); }, + }, - methods: { - postAction(action) { - const index = this.actions.indexOf(action); + methods: { + postAction(action) { + const index = this.actions.indexOf(action); - this.$set(this.actions[index], 'isLoading', true); + this.$set(this.actions[index], 'isLoading', true); - eventHub.$emit('headerPostAction', action); - }, + eventHub.$emit('headerPostAction', action); + }, - getActions() { - const actions = []; + getActions() { + const actions = []; - if (this.pipeline.retry_path) { - actions.push({ - label: 'Retry', - path: this.pipeline.retry_path, - cssClass: 'js-retry-button btn btn-inverted-secondary', - type: 'button', - isLoading: false, - }); - } + if (this.pipeline.retry_path) { + actions.push({ + label: 'Retry', + path: this.pipeline.retry_path, + cssClass: 'js-retry-button btn btn-inverted-secondary', + type: 'button', + isLoading: false, + }); + } - if (this.pipeline.cancel_path) { - actions.push({ - label: 'Cancel running', - path: this.pipeline.cancel_path, - cssClass: 'js-btn-cancel-pipeline btn btn-danger', - type: 'button', - isLoading: false, - }); - } + if (this.pipeline.cancel_path) { + actions.push({ + label: 'Cancel running', + path: this.pipeline.cancel_path, + cssClass: 'js-btn-cancel-pipeline btn btn-danger', + type: 'button', + isLoading: false, + }); + } - return actions; - }, + return actions; }, - }; + }, +}; </script> <template> <div class="pipeline-header-container"> <ci-header v-if="shouldRenderContent" :status="status" - item-name="Pipeline" :item-id="pipeline.id" :time="pipeline.created_at" :user="pipeline.user" :actions="actions" + item-name="Pipeline" @actionClicked="postAction" /> <loading-icon diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue index eba5678e3e5..9501afb7493 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/nav_controls.vue @@ -1,42 +1,42 @@ <script> - import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import LoadingButton from '../../vue_shared/components/loading_button.vue'; - export default { - name: 'PipelineNavControls', - components: { - LoadingButton, +export default { + name: 'PipelineNavControls', + components: { + LoadingButton, + }, + props: { + newPipelinePath: { + type: String, + required: false, + default: null, }, - props: { - newPipelinePath: { - type: String, - required: false, - default: null, - }, - resetCachePath: { - type: String, - required: false, - default: null, - }, + resetCachePath: { + type: String, + required: false, + default: null, + }, - ciLintPath: { - type: String, - required: false, - default: null, - }, + ciLintPath: { + type: String, + required: false, + default: null, + }, - isResetCacheButtonLoading: { - type: Boolean, - required: false, - default: false, - }, + isResetCacheButtonLoading: { + type: Boolean, + required: false, + default: false, }, - methods: { - onClickResetCache() { - this.$emit('resetRunnersCache', this.resetCachePath); - }, + }, + methods: { + onClickResetCache() { + this.$emit('resetRunnersCache', this.resetCachePath); }, - }; + }, +}; </script> <template> <div class="nav-controls"> @@ -50,10 +50,10 @@ <loading-button v-if="resetCachePath" - @click="onClickResetCache" :loading="isResetCacheButtonLoading" - class="btn btn-default js-clear-cache" :label="s__('Pipelines|Clear Runner Caches')" + class="btn btn-default js-clear-cache" + @click="onClickResetCache" /> <a diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 4d965733f95..75db1e9ae7c 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -1,49 +1,49 @@ <script> - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import tooltip from '../../vue_shared/directives/tooltip'; - import popover from '../../vue_shared/directives/popover'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; +import popover from '../../vue_shared/directives/popover'; - export default { - components: { - userAvatarLink, +export default { + components: { + userAvatarLink, + }, + directives: { + tooltip, + popover, + }, + props: { + pipeline: { + type: Object, + required: true, }, - directives: { - tooltip, - popover, + autoDevopsHelpPath: { + type: String, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, + }, + computed: { + user() { + return this.pipeline.user; }, - computed: { - user() { - return this.pipeline.user; - }, - popoverOptions() { - return { - html: true, - trigger: 'focus', - placement: 'top', - title: `<div class="autodevops-title"> + popoverOptions() { + return { + html: true, + trigger: 'focus', + placement: 'top', + title: `<div class="autodevops-title"> This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b> </div>`, - content: `<a + content: `<a class="autodevops-link" href="${this.autoDevopsHelpPath}" target="_blank" rel="noopener noreferrer nofollow"> Learn more about Auto DevOps </a>`, - }; - }, + }; }, - }; + }, +}; </script> <template> <div class="table-section section-15 d-none d-sm-none d-md-block pipeline-tags"> @@ -55,10 +55,10 @@ <span>by</span> <user-avatar-link v-if="user" - class="js-pipeline-url-user" :link-href="pipeline.user.path" :img-src="pipeline.user.avatar_url" :tooltip-text="pipeline.user.name" + class="js-pipeline-url-user" /> <span v-if="!user" @@ -67,31 +67,31 @@ </span> <div class="label-container"> <span - v-if="pipeline.flags.latest" v-tooltip + v-if="pipeline.flags.latest" class="js-pipeline-url-latest badge badge-success" title="Latest pipeline for this branch"> latest </span> <span - v-if="pipeline.flags.yaml_errors" v-tooltip - class="js-pipeline-url-yaml badge badge-danger" - :title="pipeline.yaml_errors"> + v-if="pipeline.flags.yaml_errors" + :title="pipeline.yaml_errors" + class="js-pipeline-url-yaml badge badge-danger"> yaml invalid </span> <span - v-if="pipeline.flags.failure_reason" v-tooltip - class="js-pipeline-url-failure badge badge-danger" - :title="pipeline.failure_reason"> + v-if="pipeline.flags.failure_reason" + :title="pipeline.failure_reason" + class="js-pipeline-url-failure badge badge-danger"> error </span> <a + v-popover="popoverOptions" v-if="pipeline.flags.auto_devops" tabindex="0" class="js-pipeline-url-autodevops badge badge-info autodevops-badge" - v-popover="popoverOptions" role="button"> Auto DevOps </a> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index 497a09cec65..c9d2dc3a3c5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -1,289 +1,289 @@ <script> - import _ from 'underscore'; - import { __, sprintf, s__ } from '../../locale'; - import createFlash from '../../flash'; - import PipelinesService from '../services/pipelines_service'; - import pipelinesMixin from '../mixins/pipelines'; - import TablePagination from '../../vue_shared/components/table_pagination.vue'; - import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; - import NavigationControls from './nav_controls.vue'; - import { getParameterByName } from '../../lib/utils/common_utils'; - import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; +import _ from 'underscore'; +import { __, sprintf, s__ } from '../../locale'; +import createFlash from '../../flash'; +import PipelinesService from '../services/pipelines_service'; +import pipelinesMixin from '../mixins/pipelines'; +import TablePagination from '../../vue_shared/components/table_pagination.vue'; +import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; +import NavigationControls from './nav_controls.vue'; +import { getParameterByName } from '../../lib/utils/common_utils'; +import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; - export default { - components: { - TablePagination, - NavigationTabs, - NavigationControls, +export default { + components: { + TablePagination, + NavigationTabs, + NavigationControls, + }, + mixins: [pipelinesMixin, CIPaginationMixin], + props: { + store: { + type: Object, + required: true, }, - mixins: [pipelinesMixin, CIPaginationMixin], - props: { - store: { - type: Object, - required: true, - }, - // Can be rendered in 3 different places, with some visual differences - // Accepts root | child - // `root` -> main view - // `child` -> rendered inside MR or Commit View - viewType: { - type: String, - required: false, - default: 'root', - }, - endpoint: { - type: String, - required: true, - }, - helpPagePath: { - type: String, - required: true, - }, - emptyStateSvgPath: { - type: String, - required: true, - }, - errorStateSvgPath: { - type: String, - required: true, - }, - noPipelinesSvgPath: { - type: String, - required: true, - }, - autoDevopsPath: { - type: String, - required: true, - }, - hasGitlabCi: { - type: Boolean, - required: true, - }, - canCreatePipeline: { - type: Boolean, - required: true, - }, - ciLintPath: { - type: String, - required: false, - default: null, - }, - resetCachePath: { - type: String, - required: false, - default: null, - }, - newPipelinePath: { - type: String, - required: false, - default: null, - }, + // Can be rendered in 3 different places, with some visual differences + // Accepts root | child + // `root` -> main view + // `child` -> rendered inside MR or Commit View + viewType: { + type: String, + required: false, + default: 'root', }, - data() { - return { - // Start with loading state to avoid a glitch when the empty state will be rendered - isLoading: true, - state: this.store.state, - scope: getParameterByName('scope') || 'all', - page: getParameterByName('page') || '1', - requestData: {}, - isResetCacheButtonLoading: false, - }; + endpoint: { + type: String, + required: true, }, - stateMap: { - // with tabs - loading: 'loading', - tableList: 'tableList', - error: 'error', - emptyTab: 'emptyTab', - - // without tabs - emptyState: 'emptyState', + helpPagePath: { + type: String, + required: true, + }, + emptyStateSvgPath: { + type: String, + required: true, + }, + errorStateSvgPath: { + type: String, + required: true, + }, + noPipelinesSvgPath: { + type: String, + required: true, + }, + autoDevopsPath: { + type: String, + required: true, + }, + hasGitlabCi: { + type: Boolean, + required: true, }, - scopes: { - all: 'all', - pending: 'pending', - running: 'running', - finished: 'finished', - branches: 'branches', - tags: 'tags', + canCreatePipeline: { + type: Boolean, + required: true, }, - computed: { - /** - * `hasGitlabCi` handles both internal and external CI. - * The order on which the checks are made in this method is - * important to guarantee we handle all the corner cases. - */ - stateToRender() { - const { stateMap } = this.$options; + ciLintPath: { + type: String, + required: false, + default: null, + }, + resetCachePath: { + type: String, + required: false, + default: null, + }, + newPipelinePath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + // Start with loading state to avoid a glitch when the empty state will be rendered + isLoading: true, + state: this.store.state, + scope: getParameterByName('scope') || 'all', + page: getParameterByName('page') || '1', + requestData: {}, + isResetCacheButtonLoading: false, + }; + }, + stateMap: { + // with tabs + loading: 'loading', + tableList: 'tableList', + error: 'error', + emptyTab: 'emptyTab', - if (this.isLoading) { - return stateMap.loading; - } + // without tabs + emptyState: 'emptyState', + }, + scopes: { + all: 'all', + pending: 'pending', + running: 'running', + finished: 'finished', + branches: 'branches', + tags: 'tags', + }, + computed: { + /** + * `hasGitlabCi` handles both internal and external CI. + * The order on which the checks are made in this method is + * important to guarantee we handle all the corner cases. + */ + stateToRender() { + const { stateMap } = this.$options; - if (this.hasError) { - return stateMap.error; - } + if (this.isLoading) { + return stateMap.loading; + } - if (this.state.pipelines.length) { - return stateMap.tableList; - } + if (this.hasError) { + return stateMap.error; + } - if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) { - return stateMap.emptyTab; - } + if (this.state.pipelines.length) { + return stateMap.tableList; + } - return stateMap.emptyState; - }, - /** - * Tabs are rendered in all states except empty state. - * They are not rendered before the first request to avoid a flicker on first load. - */ - shouldRenderTabs() { - const { stateMap } = this.$options; - return ( - this.hasMadeRequest && - [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes( - this.stateToRender, - ) - ); - }, + if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) { + return stateMap.emptyTab; + } + + return stateMap.emptyState; + }, + /** + * Tabs are rendered in all states except empty state. + * They are not rendered before the first request to avoid a flicker on first load. + */ + shouldRenderTabs() { + const { stateMap } = this.$options; + return ( + this.hasMadeRequest && + [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes( + this.stateToRender, + ) + ); + }, - shouldRenderButtons() { - return ( - (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs - ); - }, + shouldRenderButtons() { + return ( + (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs + ); + }, - shouldRenderPagination() { - return ( - !this.isLoading && - this.state.pipelines.length && - this.state.pageInfo.total > this.state.pageInfo.perPage - ); - }, + shouldRenderPagination() { + return ( + !this.isLoading && + this.state.pipelines.length && + this.state.pageInfo.total > this.state.pageInfo.perPage + ); + }, - emptyTabMessage() { - const { scopes } = this.$options; - const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; + emptyTabMessage() { + const { scopes } = this.$options; + const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; - if (possibleScopes.includes(this.scope)) { - return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { - scope: this.scope, - }); - } + if (possibleScopes.includes(this.scope)) { + return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { + scope: this.scope, + }); + } - return s__('Pipelines|There are currently no pipelines.'); - }, + return s__('Pipelines|There are currently no pipelines.'); + }, - tabs() { - const { count } = this.state; - const { scopes } = this.$options; + tabs() { + const { count } = this.state; + const { scopes } = this.$options; - return [ - { - name: __('All'), - scope: scopes.all, - count: count.all, - isActive: this.scope === 'all', - }, - { - name: __('Pending'), - scope: scopes.pending, - count: count.pending, - isActive: this.scope === 'pending', - }, - { - name: __('Running'), - scope: scopes.running, - count: count.running, - isActive: this.scope === 'running', - }, - { - name: __('Finished'), - scope: scopes.finished, - count: count.finished, - isActive: this.scope === 'finished', - }, - { - name: __('Branches'), - scope: scopes.branches, - isActive: this.scope === 'branches', - }, - { - name: __('Tags'), - scope: scopes.tags, - isActive: this.scope === 'tags', - }, - ]; - }, + return [ + { + name: __('All'), + scope: scopes.all, + count: count.all, + isActive: this.scope === 'all', + }, + { + name: __('Pending'), + scope: scopes.pending, + count: count.pending, + isActive: this.scope === 'pending', + }, + { + name: __('Running'), + scope: scopes.running, + count: count.running, + isActive: this.scope === 'running', + }, + { + name: __('Finished'), + scope: scopes.finished, + count: count.finished, + isActive: this.scope === 'finished', + }, + { + name: __('Branches'), + scope: scopes.branches, + isActive: this.scope === 'branches', + }, + { + name: __('Tags'), + scope: scopes.tags, + isActive: this.scope === 'tags', + }, + ]; }, - created() { - this.service = new PipelinesService(this.endpoint); - this.requestData = { page: this.page, scope: this.scope }; + }, + created() { + this.service = new PipelinesService(this.endpoint); + this.requestData = { page: this.page, scope: this.scope }; + }, + methods: { + successCallback(resp) { + // Because we are polling & the user is interacting verify if the response received + // matches the last request made + if (_.isEqual(resp.config.params, this.requestData)) { + this.store.storeCount(resp.data.count); + this.store.storePagination(resp.headers); + this.setCommonData(resp.data.pipelines); + } }, - methods: { - successCallback(resp) { - // Because we are polling & the user is interacting verify if the response received - // matches the last request made - if (_.isEqual(resp.config.params, this.requestData)) { - this.store.storeCount(resp.data.count); - this.store.storePagination(resp.headers); - this.setCommonData(resp.data.pipelines); - } - }, - /** - * Handles URL and query parameter changes. - * When the user uses the pagination or the tabs, - * - update URL - * - Make API request to the server with new parameters - * - Update the polling function - * - Update the internal state - */ - updateContent(parameters) { - this.updateInternalState(parameters); + /** + * Handles URL and query parameter changes. + * When the user uses the pagination or the tabs, + * - update URL + * - Make API request to the server with new parameters + * - Update the polling function + * - Update the internal state + */ + updateContent(parameters) { + this.updateInternalState(parameters); - // fetch new data - return this.service - .getPipelines(this.requestData) - .then(response => { - this.isLoading = false; - this.successCallback(response); + // fetch new data + return this.service + .getPipelines(this.requestData) + .then(response => { + this.isLoading = false; + this.successCallback(response); - // restart polling - this.poll.restart({ data: this.requestData }); - }) - .catch(() => { - this.isLoading = false; - this.errorCallback(); + // restart polling + this.poll.restart({ data: this.requestData }); + }) + .catch(() => { + this.isLoading = false; + this.errorCallback(); - // restart polling - this.poll.restart({ data: this.requestData }); - }); - }, + // restart polling + this.poll.restart({ data: this.requestData }); + }); + }, - handleResetRunnersCache(endpoint) { - this.isResetCacheButtonLoading = true; + handleResetRunnersCache(endpoint) { + this.isResetCacheButtonLoading = true; - this.service - .postAction(endpoint) - .then(() => { - this.isResetCacheButtonLoading = false; - createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice'); - }) - .catch(() => { - this.isResetCacheButtonLoading = false; - createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.')); - }); - }, + this.service + .postAction(endpoint) + .then(() => { + this.isResetCacheButtonLoading = false; + createFlash(s__('Pipelines|Project cache successfully reset.'), 'notice'); + }) + .catch(() => { + this.isResetCacheButtonLoading = false; + createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.')); + }); }, - }; + }, +}; </script> <template> <div class="pipelines-container"> <div - class="top-area scrolling-tabs-container inner-page-scroll-tabs" v-if="shouldRenderTabs || shouldRenderButtons" + class="top-area scrolling-tabs-container inner-page-scroll-tabs" > <div class="fade-left"> <i @@ -303,8 +303,8 @@ <navigation-tabs v-if="shouldRenderTabs" :tabs="tabs" - @onChangeTab="onChangeTab" scope="pipelines" + @onChangeTab="onChangeTab" /> <navigation-controls @@ -312,8 +312,8 @@ :new-pipeline-path="newPipelinePath" :reset-cache-path="resetCachePath" :ci-lint-path="ciLintPath" - @resetRunnersCache="handleResetRunnersCache" :is-reset-cache-button-loading="isResetCacheButtonLoading" + @resetRunnersCache="handleResetRunnersCache" /> </div> @@ -347,8 +347,8 @@ /> <div - class="table-holder" v-else-if="stateToRender === $options.stateMap.tableList" + class="table-holder" > <pipelines-table-component diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index e9bc3cf14ca..1c8d7303c52 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -1,56 +1,56 @@ <script> - import eventHub from '../event_hub'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import icon from '../../vue_shared/components/icon.vue'; - import tooltip from '../../vue_shared/directives/tooltip'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import icon from '../../vue_shared/components/icon.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + components: { + loadingIcon, + icon, + }, + props: { + actions: { + type: Array, + required: true, }, - components: { - loadingIcon, - icon, - }, - props: { - actions: { - type: Array, - required: true, - }, - }, - data() { - return { - isLoading: false, - }; - }, - methods: { - onClickAction(endpoint) { - this.isLoading = true; + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + onClickAction(endpoint) { + this.isLoading = true; - eventHub.$emit('postAction', endpoint); - }, + eventHub.$emit('postAction', endpoint); + }, - isActionDisabled(action) { - if (action.playable === undefined) { - return false; - } + isActionDisabled(action) { + if (action.playable === undefined) { + return false; + } - return !action.playable; - }, + return !action.playable; }, - }; + }, +}; </script> <template> <div class="btn-group"> <button v-tooltip + :disabled="isLoading" type="button" class="dropdown-new btn btn-default js-pipeline-dropdown-manual-actions" title="Manual job" data-toggle="dropdown" data-placement="top" aria-label="Manual job" - :disabled="isLoading" > <icon name="play" @@ -69,11 +69,11 @@ :key="i" > <button + :class="{ disabled: isActionDisabled(action) }" + :disabled="isActionDisabled(action)" type="button" class="js-pipeline-action-link no-btn btn" @click="onClickAction(action.path)" - :class="{ disabled: isActionDisabled(action) }" - :disabled="isActionDisabled(action)" > {{ action.name }} </button> diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue index 31fcc9dd412..d40de95e051 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue @@ -1,21 +1,21 @@ <script> - import tooltip from '../../vue_shared/directives/tooltip'; - import icon from '../../vue_shared/components/icon.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; +import icon from '../../vue_shared/components/icon.vue'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + components: { + icon, + }, + props: { + artifacts: { + type: Array, + required: true, }, - components: { - icon, - }, - props: { - artifacts: { - type: Array, - required: true, - }, - }, - }; + }, +}; </script> <template> <div @@ -42,9 +42,9 @@ v-for="(artifact, i) in artifacts" :key="i"> <a + :href="artifact.path" rel="nofollow" download - :href="artifact.path" > Download {{ artifact.name }} artifacts </a> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 4318abe97e0..0d7324f3fb5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -1,74 +1,82 @@ <script> - import Modal from '~/vue_shared/components/gl_modal.vue'; - import { s__, sprintf } from '~/locale'; - import PipelinesTableRowComponent from './pipelines_table_row.vue'; - import eventHub from '../event_hub'; +import Modal from '~/vue_shared/components/gl_modal.vue'; +import { s__, sprintf } from '~/locale'; +import PipelinesTableRowComponent from './pipelines_table_row.vue'; +import eventHub from '../event_hub'; - /** - * Pipelines Table Component. - * - * Given an array of objects, renders a table. - */ - export default { - components: { - PipelinesTableRowComponent, - Modal, +/** + * Pipelines Table Component. + * + * Given an array of objects, renders a table. + */ +export default { + components: { + PipelinesTableRowComponent, + Modal, + }, + props: { + pipelines: { + type: Array, + required: true, }, - props: { - pipelines: { - type: Array, - required: true, - }, - updateGraphDropdown: { - type: Boolean, - required: false, - default: false, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, - viewType: { - type: String, - required: true, - }, + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, }, - data() { - return { - pipelineId: '', - endpoint: '', - cancelingPipeline: null, - }; + autoDevopsHelpPath: { + type: String, + required: true, }, - computed: { - modalTitle() { - return sprintf(s__('Pipeline|Stop pipeline #%{pipelineId}?'), { + viewType: { + type: String, + required: true, + }, + }, + data() { + return { + pipelineId: '', + endpoint: '', + cancelingPipeline: null, + }; + }, + computed: { + modalTitle() { + return sprintf( + s__('Pipeline|Stop pipeline #%{pipelineId}?'), + { pipelineId: `${this.pipelineId}`, - }, false); - }, - modalText() { - return sprintf(s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), { - pipelineId: `<strong>#${this.pipelineId}</strong>`, - }, false); - }, + }, + false, + ); }, - created() { - eventHub.$on('openConfirmationModal', this.setModalData); + modalText() { + return sprintf( + s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'), + { + pipelineId: `<strong>#${this.pipelineId}</strong>`, + }, + false, + ); }, - beforeDestroy() { - eventHub.$off('openConfirmationModal', this.setModalData); + }, + created() { + eventHub.$on('openConfirmationModal', this.setModalData); + }, + beforeDestroy() { + eventHub.$off('openConfirmationModal', this.setModalData); + }, + methods: { + setModalData(data) { + this.pipelineId = data.pipelineId; + this.endpoint = data.endpoint; }, - methods: { - setModalData(data) { - this.pipelineId = data.pipelineId; - this.endpoint = data.endpoint; - }, - onSubmit() { - eventHub.$emit('postAction', this.endpoint); - this.cancelingPipeline = this.pipelineId; - }, + onSubmit() { + eventHub.$emit('postAction', this.endpoint); + this.cancelingPipeline = this.pipelineId; }, - }; + }, +}; </script> <template> <div class="ci-table"> @@ -114,8 +122,8 @@ <modal id="confirmation-modal" :header-title-text="modalTitle" - footer-primary-button-variant="danger" :footer-primary-button-text="s__('Pipeline|Stop pipeline')" + footer-primary-button-variant="danger" @submit="onSubmit" > <span v-html="modalText"></span> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index a3c17479e6f..804822a3ea8 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -1,255 +1,253 @@ <script> - import eventHub from '../event_hub'; - import PipelinesActionsComponent from './pipelines_actions.vue'; - import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; - import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; - import PipelineStage from './stage.vue'; - import PipelineUrl from './pipeline_url.vue'; - import PipelinesTimeago from './time_ago.vue'; - import CommitComponent from '../../vue_shared/components/commit.vue'; - import LoadingButton from '../../vue_shared/components/loading_button.vue'; - import Icon from '../../vue_shared/components/icon.vue'; - import { PIPELINES_TABLE } from '../constants'; +import eventHub from '../event_hub'; +import PipelinesActionsComponent from './pipelines_actions.vue'; +import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; +import CiBadge from '../../vue_shared/components/ci_badge_link.vue'; +import PipelineStage from './stage.vue'; +import PipelineUrl from './pipeline_url.vue'; +import PipelinesTimeago from './time_ago.vue'; +import CommitComponent from '../../vue_shared/components/commit.vue'; +import LoadingButton from '../../vue_shared/components/loading_button.vue'; +import Icon from '../../vue_shared/components/icon.vue'; +import { PIPELINES_TABLE } from '../constants'; - /** - * Pipeline table row. - * - * Given the received object renders a table row in the pipelines' table. - */ - export default { - components: { - PipelinesActionsComponent, - PipelinesArtifactsComponent, - CommitComponent, - PipelineStage, - PipelineUrl, - CiBadge, - PipelinesTimeago, - LoadingButton, - Icon, +/** + * Pipeline table row. + * + * Given the received object renders a table row in the pipelines' table. + */ +export default { + components: { + PipelinesActionsComponent, + PipelinesArtifactsComponent, + CommitComponent, + PipelineStage, + PipelineUrl, + CiBadge, + PipelinesTimeago, + LoadingButton, + Icon, + }, + props: { + pipeline: { + type: Object, + required: true, }, - props: { - pipeline: { - type: Object, - required: true, - }, - updateGraphDropdown: { - type: Boolean, - required: false, - default: false, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, - viewType: { - type: String, - required: true, - }, - cancelingPipeline: { - type: String, - required: false, - default: null, - }, + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, }, - pipelinesTable: PIPELINES_TABLE, - data() { - return { - isRetrying: false, - }; + autoDevopsHelpPath: { + type: String, + required: true, }, - computed: { - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * This field needs a lot of verification, because of different possible cases: - * - * 1. person who is an author of a commit might be a GitLab user - * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar - * 3. If GitLab user does not have avatar he/she might have a Gravatar - * 4. If committer is not a GitLab User he/she can have a Gravatar - * 5. We do not have consistent API object in this case - * 6. We should improve API and the code - * - * @returns {Object|Undefined} - */ - commitAuthor() { - let commitAuthorInformation; + viewType: { + type: String, + required: true, + }, + cancelingPipeline: { + type: String, + required: false, + default: null, + }, + }, + pipelinesTable: PIPELINES_TABLE, + data() { + return { + isRetrying: false, + }; + }, + computed: { + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * This field needs a lot of verification, because of different possible cases: + * + * 1. person who is an author of a commit might be a GitLab user + * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar + * 3. If GitLab user does not have avatar he/she might have a Gravatar + * 4. If committer is not a GitLab User he/she can have a Gravatar + * 5. We do not have consistent API object in this case + * 6. We should improve API and the code + * + * @returns {Object|Undefined} + */ + commitAuthor() { + let commitAuthorInformation; - if (!this.pipeline || !this.pipeline.commit) { - return null; - } + if (!this.pipeline || !this.pipeline.commit) { + return null; + } - // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline.commit.author) { - // 2. if person who is an author of a commit is a GitLab user - // he/she can have a GitLab avatar - if (this.pipeline.commit.author.avatar_url) { - commitAuthorInformation = this.pipeline.commit.author; + // 1. person who is an author of a commit might be a GitLab user + if (this.pipeline.commit.author) { + // 2. if person who is an author of a commit is a GitLab user + // he/she can have a GitLab avatar + if (this.pipeline.commit.author.avatar_url) { + commitAuthorInformation = this.pipeline.commit.author; - // 3. If GitLab user does not have avatar he/she might have a Gravatar - } else if (this.pipeline.commit.author_gravatar_url) { - commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { - avatar_url: this.pipeline.commit.author_gravatar_url, - }); - } - // 4. If committer is not a GitLab User he/she can have a Gravatar - } else { - commitAuthorInformation = { + // 3. If GitLab user does not have avatar he/she might have a Gravatar + } else if (this.pipeline.commit.author_gravatar_url) { + commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { avatar_url: this.pipeline.commit.author_gravatar_url, - path: `mailto:${this.pipeline.commit.author_email}`, - username: this.pipeline.commit.author_name, - }; + }); } + // 4. If committer is not a GitLab User he/she can have a Gravatar + } else { + commitAuthorInformation = { + avatar_url: this.pipeline.commit.author_gravatar_url, + path: `mailto:${this.pipeline.commit.author_email}`, + username: this.pipeline.commit.author_name, + }; + } - return commitAuthorInformation; - }, + return commitAuthorInformation; + }, - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitTag() { - if (this.pipeline.ref && - this.pipeline.ref.tag) { - return this.pipeline.ref.tag; - } - return undefined; - }, + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTag() { + if (this.pipeline.ref && this.pipeline.ref.tag) { + return this.pipeline.ref.tag; + } + return undefined; + }, - /** - * If provided, returns the commit ref. - * Needed to render the commit component column. - * - * Matches `path` prop sent in the API to `ref_url` prop needed - * in the commit component. - * - * @returns {Object|Undefined} - */ - commitRef() { - if (this.pipeline.ref) { - return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { - if (prop === 'path') { - // eslint-disable-next-line no-param-reassign - accumulator.ref_url = this.pipeline.ref[prop]; - } else { - // eslint-disable-next-line no-param-reassign - accumulator[prop] = this.pipeline.ref[prop]; - } - return accumulator; - }, {}); - } + /** + * If provided, returns the commit ref. + * Needed to render the commit component column. + * + * Matches `path` prop sent in the API to `ref_url` prop needed + * in the commit component. + * + * @returns {Object|Undefined} + */ + commitRef() { + if (this.pipeline.ref) { + return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { + if (prop === 'path') { + // eslint-disable-next-line no-param-reassign + accumulator.ref_url = this.pipeline.ref[prop]; + } else { + // eslint-disable-next-line no-param-reassign + accumulator[prop] = this.pipeline.ref[prop]; + } + return accumulator; + }, {}); + } - return undefined; - }, + return undefined; + }, - /** - * If provided, returns the commit url. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitUrl() { - if (this.pipeline.commit && - this.pipeline.commit.commit_path) { - return this.pipeline.commit.commit_path; - } - return undefined; - }, + /** + * If provided, returns the commit url. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitUrl() { + if (this.pipeline.commit && this.pipeline.commit.commit_path) { + return this.pipeline.commit.commit_path; + } + return undefined; + }, - /** - * If provided, returns the commit short sha. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitShortSha() { - if (this.pipeline.commit && - this.pipeline.commit.short_id) { - return this.pipeline.commit.short_id; - } - return undefined; - }, + /** + * If provided, returns the commit short sha. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitShortSha() { + if (this.pipeline.commit && this.pipeline.commit.short_id) { + return this.pipeline.commit.short_id; + } + return undefined; + }, - /** - * If provided, returns the commit title. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitTitle() { - if (this.pipeline.commit && - this.pipeline.commit.title) { - return this.pipeline.commit.title; - } - return undefined; - }, + /** + * If provided, returns the commit title. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTitle() { + if (this.pipeline.commit && this.pipeline.commit.title) { + return this.pipeline.commit.title; + } + return undefined; + }, - /** - * Timeago components expects a number - * - * @return {type} description - */ - pipelineDuration() { - if (this.pipeline.details && this.pipeline.details.duration) { - return this.pipeline.details.duration; - } + /** + * Timeago components expects a number + * + * @return {type} description + */ + pipelineDuration() { + if (this.pipeline.details && this.pipeline.details.duration) { + return this.pipeline.details.duration; + } - return 0; - }, + return 0; + }, - /** - * Timeago component expects a String. - * - * @return {String} - */ - pipelineFinishedAt() { - if (this.pipeline.details && this.pipeline.details.finished_at) { - return this.pipeline.details.finished_at; - } + /** + * Timeago component expects a String. + * + * @return {String} + */ + pipelineFinishedAt() { + if (this.pipeline.details && this.pipeline.details.finished_at) { + return this.pipeline.details.finished_at; + } - return ''; - }, + return ''; + }, - pipelineStatus() { - if (this.pipeline.details && this.pipeline.details.status) { - return this.pipeline.details.status; - } - return {}; - }, + pipelineStatus() { + if (this.pipeline.details && this.pipeline.details.status) { + return this.pipeline.details.status; + } + return {}; + }, - displayPipelineActions() { - return this.pipeline.flags.retryable || - this.pipeline.flags.cancelable || - this.pipeline.details.manual_actions.length || - this.pipeline.details.artifacts.length; - }, + displayPipelineActions() { + return ( + this.pipeline.flags.retryable || + this.pipeline.flags.cancelable || + this.pipeline.details.manual_actions.length || + this.pipeline.details.artifacts.length + ); + }, - isChildView() { - return this.viewType === 'child'; - }, + isChildView() { + return this.viewType === 'child'; + }, - isCancelling() { - return this.cancelingPipeline === this.pipeline.id; - }, + isCancelling() { + return this.cancelingPipeline === this.pipeline.id; }, + }, - methods: { - handleCancelClick() { - eventHub.$emit('openConfirmationModal', { - pipelineId: this.pipeline.id, - endpoint: this.pipeline.cancel_path, - }); - }, - handleRetryClick() { - this.isRetrying = true; - eventHub.$emit('retryPipeline', this.pipeline.retry_path); - }, + methods: { + handleCancelClick() { + eventHub.$emit('openConfirmationModal', { + pipelineId: this.pipeline.id, + endpoint: this.pipeline.cancel_path, + }); }, - }; + handleRetryClick() { + this.isRetrying = true; + eventHub.$emit('retryPipeline', this.pipeline.retry_path); + }, + }, +}; </script> <template> <div class="commit gl-responsive-table-row"> @@ -301,9 +299,9 @@ <div class="table-mobile-content"> <template v-if="pipeline.details.stages.length > 0"> <div - class="stage-container dropdown js-mini-pipeline-graph" v-for="(stage, index) in pipeline.details.stages" - :key="index"> + :key="index" + class="stage-container dropdown js-mini-pipeline-graph"> <pipeline-stage :type="$options.pipelinesTable" :stage="stage" @@ -331,28 +329,28 @@ <pipelines-artifacts-component v-if="pipeline.details.artifacts.length" - class="d-none d-sm-none d-md-block" :artifacts="pipeline.details.artifacts" + class="d-none d-sm-none d-md-block" /> <loading-button v-if="pipeline.flags.retryable" - @click="handleRetryClick" - container-class="js-pipelines-retry-button btn btn-default btn-retry" :loading="isRetrying" :disabled="isRetrying" + container-class="js-pipelines-retry-button btn btn-default btn-retry" + @click="handleRetryClick" > <icon name="repeat" /> </loading-button> <loading-button v-if="pipeline.flags.cancelable" - @click="handleCancelClick" + :loading="isCancelling" + :disabled="isCancelling" data-toggle="modal" data-target="#confirmation-modal" container-class="js-pipelines-cancel-button btn btn-remove" - :loading="isCancelling" - :disabled="isCancelling" + @click="handleCancelClick" > <icon name="close" /> </loading-button> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index f9769815796..56fdb858088 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -158,22 +158,23 @@ export default { <div class="dropdown"> <button v-tooltip + id="stageDropdown" + ref="dropdown" :class="triggerButtonClass" - @click="onClickStage" - class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button" :title="stage.title" + class="mini-pipeline-graph-dropdown-toggle js-builds-dropdown-button" data-placement="top" data-toggle="dropdown" + data-display="static" type="button" - id="stageDropdown" aria-haspopup="true" aria-expanded="false" - ref="dropdown" + @click="onClickStage" > <span - aria-hidden="true" :aria-label="stage.title" + aria-hidden="true" > <icon :name="borderlessIcon" /> </span> @@ -185,32 +186,27 @@ export default { </i> </button> - <ul + <div class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" aria-labelledby="stageDropdown" > - - <li + <loading-icon v-if="isLoading"/> + <ul + v-else class="js-builds-dropdown-list scrollable-menu" > - - <loading-icon v-if="isLoading"/> - - <ul - v-else + <li + v-for="job in dropdownContent" + :key="job.id" > - <li - v-for="job in dropdownContent" - :key="job.id" - > - <job-component - :job="job" - css-class-job-name="mini-pipeline-graph-dropdown-item" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </li> - </ul> - </li> - </ul> + <job-component + :dropdown-length="dropdownContent.length" + :job="job" + css-class-job-name="mini-pipeline-graph-dropdown-item" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </li> + </ul> + </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/time_ago.vue index 79dbdca4010..cd43d78de40 100644 --- a/app/assets/javascripts/pipelines/components/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/time_ago.vue @@ -1,60 +1,58 @@ <script> - import iconTimerSvg from 'icons/_icon_timer.svg'; - import '../../lib/utils/datetime_utility'; - import tooltip from '../../vue_shared/directives/tooltip'; - import timeagoMixin from '../../vue_shared/mixins/timeago'; +import iconTimerSvg from 'icons/_icon_timer.svg'; +import '../../lib/utils/datetime_utility'; +import tooltip from '../../vue_shared/directives/tooltip'; +import timeagoMixin from '../../vue_shared/mixins/timeago'; - export default { - directives: { - tooltip, +export default { + directives: { + tooltip, + }, + mixins: [timeagoMixin], + props: { + finishedTime: { + type: String, + required: true, }, - mixins: [ - timeagoMixin, - ], - props: { - finishedTime: { - type: String, - required: true, - }, - duration: { - type: Number, - required: true, - }, + duration: { + type: Number, + required: true, }, - data() { - return { - iconTimerSvg, - }; + }, + data() { + return { + iconTimerSvg, + }; + }, + computed: { + hasDuration() { + return this.duration > 0; }, - computed: { - hasDuration() { - return this.duration > 0; - }, - hasFinishedTime() { - return this.finishedTime !== ''; - }, - durationFormated() { - const date = new Date(this.duration * 1000); + hasFinishedTime() { + return this.finishedTime !== ''; + }, + durationFormated() { + const date = new Date(this.duration * 1000); - let hh = date.getUTCHours(); - let mm = date.getUTCMinutes(); - let ss = date.getSeconds(); + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); - // left pad - if (hh < 10) { - hh = `0${hh}`; - } - if (mm < 10) { - mm = `0${mm}`; - } - if (ss < 10) { - ss = `0${ss}`; - } + // left pad + if (hh < 10) { + hh = `0${hh}`; + } + if (mm < 10) { + mm = `0${mm}`; + } + if (ss < 10) { + ss = `0${ss}`; + } - return `${hh}:${mm}:${ss}`; - }, + return `${hh}:${mm}:${ss}`; }, - }; + }, +}; </script> <template> <div class="table-section section-15 pipelines-time-ago"> @@ -66,8 +64,8 @@ </div> <div class="table-mobile-content"> <p - class="duration" v-if="hasDuration" + class="duration" > <span v-html="iconTimerSvg"> </span> @@ -75,8 +73,8 @@ </p> <p - class="finished-at d-none d-sm-none d-md-block" v-if="hasFinishedTime" + class="finished-at d-none d-sm-none d-md-block" > <i @@ -87,9 +85,9 @@ <time v-tooltip + :title="tooltipTitle(finishedTime)" data-placement="top" - data-container="body" - :title="tooltipTitle(finishedTime)"> + data-container="body"> {{ timeFormated(finishedTime) }} </time> </p> diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 30b1eee186d..2cb558b0dec 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -75,8 +75,7 @@ export default { // Stop polling this.poll.stop(); // Update the table - return this.getPipelines() - .then(() => this.poll.restart()); + return this.getPipelines().then(() => this.poll.restart()); }, fetchPipelines() { if (!this.isMakingRequest) { @@ -86,9 +85,10 @@ export default { } }, getPipelines() { - return this.service.getPipelines(this.requestData) + return this.service + .getPipelines(this.requestData) .then(response => this.successCallback(response)) - .catch((error) => this.errorCallback(error)); + .catch(error => this.errorCallback(error)); }, setCommonData(pipelines) { this.store.storePipelines(pipelines); @@ -118,7 +118,8 @@ export default { } }, postAction(endpoint) { - this.service.postAction(endpoint) + this.service + .postAction(endpoint) .then(() => this.fetchPipelines()) .catch(() => Flash(__('An error occurred while making the request.'))); }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index b49a16a87e6..dc9befe6349 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -10,7 +10,7 @@ import eventHub from './event_hub'; Vue.use(Translate); export default () => { - const dataset = document.querySelector('.js-pipeline-details-vue').dataset; + const { dataset } = document.querySelector('.js-pipeline-details-vue'); const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); @@ -31,7 +31,8 @@ export default () => { requestRefreshPipelineGraph() { // When an action is clicked // (wether in the dropdown or in the main nodes, we refresh the big graph) - this.mediator.refreshPipeline() + this.mediator + .refreshPipeline() .catch(() => Flash(__('An error occurred while making the request.'))); }, }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index 5633e54b28a..bd1e1895660 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -52,7 +52,8 @@ export default class pipelinesMediator { refreshPipeline() { this.poll.stop(); - return this.service.getPipeline() + return this.service + .getPipeline() .then(response => this.successCallback(response)) .catch(() => this.errorCallback()) .finally(() => this.poll.restart()); diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 59c8b9c58e5..8317d3f4510 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -19,7 +19,7 @@ export default class PipelinesService { getPipelines(data = {}) { const { scope, page } = data; - const CancelToken = axios.CancelToken; + const { CancelToken } = axios; this.cancelationSource = CancelToken.source(); diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 246a265ef2b..0e973cab4d2 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, object-shorthand, comma-dangle, prefer-arrow-callback */ +/* eslint-disable func-names, no-var, object-shorthand, prefer-arrow-callback */ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; @@ -43,7 +43,7 @@ MarkdownPreview.prototype.showPreview = function ($form) { this.fetchMarkdownPreview(mdText, url, (function (response) { var body; if (response.body.length > 0) { - body = response.body; + ({ body } = response); } else { body = this.emptyMessage; } diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index f50002afbf2..974629fa2af 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -80,10 +80,10 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), id="delete-account-modal" :title="s__('Profiles|Delete your account?')" :text="text" - kind="danger" :primary-button-label="s__('Profiles|Delete account')" - @submit="onSubmit" - :submit-disabled="!canSubmit()"> + :submit-disabled="!canSubmit()" + kind="danger" + @submit="onSubmit"> <template slot="body" @@ -101,9 +101,9 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), value="delete" /> <input + :value="csrfToken" type="hidden" name="authenticity_token" - :value="csrfToken" /> <p @@ -114,18 +114,18 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), <input v-if="confirmWithPassword" + v-model="enteredPassword" name="password" class="form-control" type="password" - v-model="enteredPassword" aria-labelledby="input-label" /> <input v-else + v-model="enteredUsername" name="username" class="form-control" type="text" - v-model="enteredUsername" aria-labelledby="input-label" /> </form> diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index b37febe523c..ef484ddfd61 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -93,10 +93,10 @@ Please update your Git repository remotes as soon as possible.`), </div> <input :id="$options.inputId" - class="form-control" - required="required" v-model="newUsername" :disabled="isRequestPending" + class="form-control" + required="required" /> </div> <p class="form-text text-muted"> @@ -105,18 +105,18 @@ Please update your Git repository remotes as soon as possible.`), </div> <button :data-target="`#${$options.modalId}`" + :disabled="isRequestPending || newUsername === username" class="btn btn-warning" type="button" data-toggle="modal" - :disabled="isRequestPending || newUsername === username" > {{ $options.buttonText }} </button> <gl-modal :id="$options.modalId" :header-title-text="s__('Profiles|Change username') + '?'" - footer-primary-button-variant="warning" :footer-primary-button-text="$options.buttonText" + footer-primary-button-variant="warning" @submit="onConfirm" > <span v-html="modalText"></span> diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 8f93156cdd1..f641b23e519 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-escape, max-len, quotes, no-var, no-underscore-dangle, func-names, space-before-function-paren, no-unused-vars, no-return-assign, object-shorthand, one-var, one-var-declaration-per-line, comma-dangle, consistent-return, class-methods-use-this, new-parens */ +/* eslint-disable no-useless-escape, max-len, no-var, no-underscore-dangle, func-names, no-unused-vars, no-return-assign, object-shorthand, one-var, one-var-declaration-per-line, comma-dangle, consistent-return, class-methods-use-this, new-parens */ import $ from 'jquery'; import 'cropper'; @@ -47,7 +47,8 @@ import _ from 'underscore'; var _this; _this = this; this.fileInput.on('change', function(e) { - return _this.onFileInputChange(e, this); + _this.onFileInputChange(e, this); + this.value = null; }); this.pickImageEl.on('click', this.onPickImageClick); this.modalCrop.on('shown.bs.modal', this.onModalShow); @@ -85,11 +86,10 @@ import _ from 'underscore'; cropBoxResizable: false, toggleDragModeOnDblclick: false, built: function() { - var $image, container, cropBoxHeight, cropBoxWidth; - $image = $(this); - container = $image.cropper('getContainerData'); - cropBoxWidth = _this.cropBoxWidth; - cropBoxHeight = _this.cropBoxHeight; + const $image = $(this); + const container = $image.cropper('getContainerData'); + const { cropBoxWidth, cropBoxHeight } = _this; + return $image.cropper('setCropBoxData', { width: cropBoxWidth, height: cropBoxHeight, @@ -136,12 +136,13 @@ import _ from 'underscore'; } dataURLtoBlob(dataURL) { - var array, binary, i, k, len, v; + var array, binary, i, len, v; binary = atob(dataURL.split(',')[1]); array = []; - for (k = i = 0, len = binary.length; i < len; k = (i += 1)) { - v = binary[k]; - array.push(binary.charCodeAt(k)); + + for (i = 0, len = binary.length; i < len; i += 1) { + v = binary[i]; + array.push(binary.charCodeAt(i)); } return new Blob([new Uint8Array(array)], { type: 'image/png' diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 0af34657d72..8cf7f2f23d0 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,8 +1,5 @@ -/* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */ - import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; import flash from '../flash'; export default class Profile { @@ -64,7 +61,13 @@ export default class Profile { url: this.form.attr('action'), data: formData, }) - .then(({ data }) => flash(data.message, 'notice')) + .then(({ data }) => { + if (avatarBlob != null) { + this.updateHeaderAvatar(); + } + + flash(data.message, 'notice'); + }) .then(() => { window.scrollTo(0, 0); // Enable submit button after requests ends @@ -73,6 +76,10 @@ export default class Profile { .catch(error => flash(error.message)); } + updateHeaderAvatar() { + $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL); + } + setRepoRadio() { const multiEditRadios = $('input[name="user[multi_file]"]'); if (this.newRepoActivated || this.newRepoActivated === 'true') { diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 4c4acd487f8..05485e352dc 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, consistent-return, one-var, one-var-declaration-per-line, no-cond-assign, max-len, object-shorthand, no-param-reassign, comma-dangle, prefer-template, no-unused-vars, no-return-assign */ +/* eslint-disable func-names, no-var, wrap-iife, quotes, consistent-return, one-var, one-var-declaration-per-line, no-cond-assign, max-len, prefer-template, no-unused-vars, no-return-assign */ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; @@ -88,10 +88,11 @@ export default class ProjectFindFile { // render result renderList(filePaths, searchText) { - var blobItemUrl, filePath, html, i, j, len, matches, results; + var blobItemUrl, filePath, html, i, len, matches, results; this.element.find(".tree-table > tbody").empty(); results = []; - for (i = j = 0, len = filePaths.length; j < len; i = (j += 1)) { + + for (i = 0, len = filePaths.length; i < len; i += 1) { filePath = filePaths[i]; if (i === 20) { break; @@ -150,7 +151,7 @@ export default class ProjectFindFile { } goToTree() { - return location.href = this.options.treeUrl; + return window.location.href = this.options.treeUrl; } goToBlob() { diff --git a/app/assets/javascripts/project_import.js b/app/assets/javascripts/project_import.js index d2d26d6f67e..5a0d2b642eb 100644 --- a/app/assets/javascripts/project_import.js +++ b/app/assets/javascripts/project_import.js @@ -2,7 +2,7 @@ import { visitUrl } from './lib/utils/url_utility'; export default function projectImport() { setTimeout(() => { - visitUrl(location.href); + visitUrl(window.location.href); }, 5000); } diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js index 6f06944ebb6..9049f87e037 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/project_label_subscription.js @@ -3,6 +3,17 @@ import { __ } from './locale'; import axios from './lib/utils/axios_utils'; import flash from './flash'; +const tooltipTitles = { + group: { + subscribed: __('Unsubscribe at group level'), + unsubscribed: __('Subscribe at group level'), + }, + project: { + subscribed: __('Unsubscribe at project level'), + unsubscribed: __('Subscribe at project level'), + }, +}; + export default class ProjectLabelSubscription { constructor(container) { this.$container = $(container); @@ -15,12 +26,10 @@ export default class ProjectLabelSubscription { event.preventDefault(); const $btn = $(event.currentTarget); - const $span = $btn.find('span'); const url = $btn.attr('data-url'); const oldStatus = $btn.attr('data-status'); $btn.addClass('disabled'); - $span.toggleClass('hidden'); axios.post(url).then(() => { let newStatus; @@ -32,21 +41,28 @@ export default class ProjectLabelSubscription { [newStatus, newAction] = ['unsubscribed', 'Subscribe']; } - $span.toggleClass('hidden'); $btn.removeClass('disabled'); this.$buttons.attr('data-status', newStatus); this.$buttons.find('> span').text(newAction); - this.$buttons.map((button) => { + this.$buttons.map((i, button) => { const $button = $(button); + const originalTitle = $button.attr('data-original-title'); - if ($button.attr('data-original-title')) { - $button.tooltip('hide').attr('data-original-title', newAction).tooltip('_fixTitle'); + if (originalTitle) { + ProjectLabelSubscription.setNewTitle($button, originalTitle, newStatus, newAction); } return button; }); }).catch(() => flash(__('There was an error subscribing to this label.'))); } + + static setNewTitle($button, originalTitle, newStatus) { + const type = /group/.test(originalTitle) ? 'group' : 'project'; + const newTitle = tooltipTitles[type][newStatus]; + + $button.attr('title', newTitle).tooltip('_fixTitle'); + } } diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index cb2e6855d1d..bce7556bd40 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */ +/* eslint-disable func-names, wrap-iife, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */ import $ from 'jquery'; import Api from './api'; @@ -47,7 +47,10 @@ export default function projectSelect() { projectsCallback = finalCallback; } if (_this.groupId) { - return Api.groupProjects(_this.groupId, query.term, projectsCallback); + return Api.groupProjects(_this.groupId, query.term, { + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + }, projectsCallback); } else { return Api.projects(query.term, { order_by: _this.orderBy, diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue index ab7d2d41ece..d4497924ad8 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue @@ -89,14 +89,13 @@ export default { <div> <div class="js-gcp-machine-type-dropdown dropdown" - :class="{ 'gl-show-field-errors': hasErrors }" > <dropdown-hidden-input :name="fieldName" :value="selectedMachineType" /> <dropdown-button - :class="{ 'gl-field-error-outline': hasErrors }" + :class="{ 'border-danger': hasErrors }" :is-disabled="isDisabled" :is-loading="isLoading" :toggle-text="toggleText" @@ -132,9 +131,12 @@ export default { </div> </div> <span - class="form-text text-muted" - :class="{ 'gl-field-error': hasErrors }" v-if="hasErrors" + :class="{ + 'text-danger': hasErrors, + 'text-muted': !hasErrors + }" + class="form-text" > {{ errorMessage }} </span> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue index 25350ef0fa9..08d0a122579 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue @@ -147,7 +147,6 @@ export default { <div> <div class="js-gcp-project-id-dropdown dropdown" - :class="{ 'gl-show-field-errors': hasErrors }" > <dropdown-hidden-input :name="fieldName" @@ -155,7 +154,7 @@ export default { /> <dropdown-button :class="{ - 'gl-field-error-outline': hasErrors, + 'border-danger': hasErrors, 'read-only': hasOneProject }" :is-disabled="isDisabled" @@ -193,8 +192,11 @@ export default { </div> </div> <span - class="form-text text-muted" - :class="{ 'gl-field-error': hasErrors }" + :class="{ + 'text-danger': hasErrors, + 'text-muted': !hasErrors + }" + class="form-text" v-html="helpText" ></span> </div> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue index 8ee4eefcd91..b5476684c6a 100644 --- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue @@ -63,14 +63,13 @@ export default { <div> <div class="js-gcp-zone-dropdown dropdown" - :class="{ 'gl-show-field-errors': hasErrors }" > <dropdown-hidden-input :name="fieldName" :value="selectedZone" /> <dropdown-button - :class="{ 'gl-field-error-outline': hasErrors }" + :class="{ 'border-danger': hasErrors }" :is-disabled="isDisabled" :is-loading="isLoading" :toggle-text="toggleText" @@ -106,9 +105,12 @@ export default { </div> </div> <span - class="form-text text-muted" - :class="{ 'gl-field-error': hasErrors }" v-if="hasErrors" + :class="{ + 'text-danger': hasErrors, + 'text-muted': !hasErrors + }" + class="form-text" > {{ errorMessage }} </span> diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 888b1d6ce33..002edb4663c 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -90,7 +90,7 @@ const bindEvents = () => { function chooseTemplate() { $('.template-option').hide(); $projectFieldsForm.addClass('selected'); - $selectedIcon.removeClass('active'); + $selectedIcon.removeClass('d-block'); const value = $(this).val(); const templates = { rails: { @@ -109,7 +109,7 @@ const bindEvents = () => { const selectedTemplate = templates[value]; $selectedTemplateText.text(selectedTemplate.text); - $(selectedTemplate.icon).addClass('active'); + $(selectedTemplate.icon).addClass('d-block'); $templateProjectNameInput.focus(); } diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index 63f20a0041d..a4c7c143e56 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -47,7 +47,7 @@ }, methods: { successCallback(res) { - const pipelines = res.data.pipelines; + const { pipelines } = res.data; if (pipelines.length > 0) { // The pipeline entity always keeps the latest pipeline info on the `details.status` this.ciStatus = pipelines[0].details.status; @@ -100,9 +100,9 @@ <template> <div> <loading-icon + v-if="isLoading" label="Loading pipeline status" size="3" - v-if="isLoading" /> <a v-else @@ -112,8 +112,8 @@ v-tooltip :title="statusTitle" :aria-label="statusTitle" - data-container="body" :status="ciStatus" + data-container="body" /> </a> </div> diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue index 0bbd8a41753..73d49488299 100644 --- a/app/assets/javascripts/projects_dropdown/components/app.vue +++ b/app/assets/javascripts/projects_dropdown/components/app.vue @@ -132,14 +132,14 @@ export default { <div> <search/> <loading-icon - class="loading-animation prepend-top-20" - size="2" v-if="isLoadingProjects" :label="s__('ProjectsDropdown|Loading projects')" + class="loading-animation prepend-top-20" + size="2" /> <div - class="section-header" v-if="isFrequentsListVisible" + class="section-header" > {{ s__('ProjectsDropdown|Frequently visited') }} </div> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue index 246dbeaaded..625e0aa548c 100644 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue @@ -37,14 +37,14 @@ class="list-unstyled" > <li - class="section-empty" v-if="isListEmpty" + class="section-empty" > {{ listEmptyMessage }} </li> <projects-list-item - v-else v-for="(project, index) in projects" + v-else :key="index" :project-id="project.id" :project-name="project.name" 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 759cdd1ded9..eafbf6c99e2 100644 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue @@ -79,36 +79,36 @@ class="projects-list-item-container" > <a - class="clearfix" :href="webUrl" + class="clearfix" > <div class="project-item-avatar-container" > <img v-if="hasAvatar" - class="avatar s32" :src="avatarUrl" + class="avatar s32" /> <identicon v-else - size-class="s32" :entity-id="projectId" :entity-name="projectName" + size-class="s32" /> </div> <div class="project-item-metadata-container" > <div - class="project-title" :title="projectName" + class="project-title" v-html="highlightedProjectName" > </div> <div - class="project-namespace" :title="namespace" + class="project-namespace" >{{ truncatedNamespace }}</div> </div> </a> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue index 8d0c29177e6..76e9cb9e53f 100644 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue @@ -48,8 +48,8 @@ export default { {{ listEmptyMessage }} </li> <projects-list-item - v-else v-for="(project, index) in projects" + v-else :key="index" :project-id="project.id" :project-name="project.name" diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue index 7fcd62dcdb8..28f2a18f2a6 100644 --- a/app/assets/javascripts/projects_dropdown/components/search.vue +++ b/app/assets/javascripts/projects_dropdown/components/search.vue @@ -49,11 +49,11 @@ class="search-input-container d-none d-sm-block" > <input - type="search" - class="form-control" ref="search" v-model="searchQuery" :placeholder="s__('ProjectsDropdown|Search your projects')" + type="search" + class="form-control" /> <i v-if="!searchQuery" diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js index e1ca70c51a6..6056f12aa4f 100644 --- a/app/assets/javascripts/projects_dropdown/index.js +++ b/app/assets/javascripts/projects_dropdown/index.js @@ -31,7 +31,7 @@ document.addEventListener('DOMContentLoaded', () => { projectsDropdownApp, }, data() { - const dataset = this.$options.el.dataset; + const { dataset } = this.$options.el; const store = new ProjectsStore(); const service = new ProjectsService(dataset.userName); diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js index 1a75fdd75db..078ccbbbac2 100644 --- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js +++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js @@ -107,7 +107,7 @@ export default class PrometheusMetrics { if (data && data.success) { stop(data); } else { - this.backOffRequestCounter = this.backOffRequestCounter += 1; + this.backOffRequestCounter += 1; if (this.backOffRequestCounter < 3) { next(); } else { diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 7c61c070a35..b601b19e7be 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -1,11 +1,8 @@ import $ from 'jquery'; -import _ from 'underscore'; import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; import CreateItemDropdown from '../create_item_dropdown'; import AccessorUtilities from '../lib/utils/accessor'; -const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults'; - export default class ProtectedBranchCreate { constructor() { this.$form = $('.js-new-protected-branch'); @@ -43,8 +40,6 @@ export default class ProtectedBranchCreate { onSelect: this.onSelectCallback, getData: ProtectedBranchCreate.getProtectedBranches, }); - - this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown')); } // This will run after clicked callback @@ -59,39 +54,10 @@ export default class ProtectedBranchCreate { $allowedToPushInput.length ); - this.savePreviousSelection($allowedToMergeInput.val(), $allowedToPushInput.val()); this.$form.find('input[type="submit"]').prop('disabled', completedForm); } static getProtectedBranches(term, callback) { callback(gon.open_branches); } - - loadPreviousSelection(mergeDropdown, pushDropdown) { - let mergeIndex = 0; - let pushIndex = 0; - if (this.isLocalStorageAvailable) { - const savedDefaults = JSON.parse(window.localStorage.getItem(PB_LOCAL_STORAGE_KEY)); - if (savedDefaults != null) { - mergeIndex = _.findLastIndex(mergeDropdown.fullData.roles, { - id: parseInt(savedDefaults.mergeSelection, 0), - }); - pushIndex = _.findLastIndex(pushDropdown.fullData.roles, { - id: parseInt(savedDefaults.pushSelection, 0), - }); - } - } - mergeDropdown.selectRowAtIndex(mergeIndex); - pushDropdown.selectRowAtIndex(pushIndex); - } - - savePreviousSelection(mergeSelection, pushSelection) { - if (this.isLocalStorageAvailable) { - const branchDefaults = { - mergeSelection, - pushSelection, - }; - window.localStorage.setItem(PB_LOCAL_STORAGE_KEY, JSON.stringify(branchDefaults)); - } - } } diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index ea0f7199a70..31f88675912 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -48,8 +48,8 @@ /> <collapsible-container - v-else-if="!isLoading && repos.length" v-for="(item, index) in repos" + v-else-if="!isLoading && repos.length" :key="index" :repo="item" /> diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 2fc3778820b..4116c4a0489 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -62,15 +62,15 @@ <div class="container-image-head"> <button type="button" - @click="toggleRepo" class="js-toggle-repo btn-link" + @click="toggleRepo" > <i - class="fa" :class="{ 'fa-chevron-right': !isOpen, 'fa-chevron-up': isOpen, }" + class="fa" aria-hidden="true" > </i> @@ -86,12 +86,12 @@ <div class="controls d-none d-sm-block float-right"> <button + v-tooltip v-if="repo.canDelete" - type="button" - class="js-remove-repo btn btn-danger" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - v-tooltip + type="button" + class="js-remove-repo btn btn-danger" @click="handleDeleteRepository" > <i diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index e4a4b3bb129..9f4973c3490 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -118,13 +118,13 @@ <td class="content"> <button + v-tooltip v-if="item.canDelete" - type="button" - class="js-delete-registry btn btn-danger d-none d-sm-block float-right" :title="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')" + type="button" + class="js-delete-registry btn btn-danger d-none d-sm-block float-right" data-container="body" - v-tooltip @click="handleDeleteRegistry(item)" > <i diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js index 6fb125192b2..e15cd94a915 100644 --- a/app/assets/javascripts/registry/index.js +++ b/app/assets/javascripts/registry/index.js @@ -10,7 +10,7 @@ export default () => new Vue({ registryApp, }, data() { - const dataset = document.querySelector(this.$options.el).dataset; + const { dataset } = document.querySelector(this.$options.el); return { endpoint: dataset.endpoint, }; diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index c0de03373d8..a78aa90b7b5 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -20,7 +20,7 @@ export const fetchList = ({ commit }, { repo, page }) => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); return Vue.http.get(repo.tagsPath, { params: { page } }).then(response => { - const headers = response.headers; + const { headers } = response; return response.json().then(resp => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 2afcf4626b8..b27d635c6ac 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, object-shorthand, comma-dangle, no-else-return, no-param-reassign, max-len */ +/* eslint-disable func-names, no-var, no-unused-vars, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, no-else-return, no-param-reassign, max-len */ import $ from 'jquery'; import _ from 'underscore'; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 2da022fde63..5b2e0468784 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, no-cond-assign, consistent-return, object-shorthand, prefer-arrow-callback, func-names, space-before-function-paren, prefer-template, quotes, class-methods-use-this, no-sequences, wrap-iife, no-lonely-if, no-else-return, no-param-reassign, vars-on-top, max-len */ +/* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, consistent-return, object-shorthand, prefer-template, quotes, class-methods-use-this, no-lonely-if, no-else-return, vars-on-top, max-len */ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; @@ -289,7 +289,7 @@ export default class SearchAutocomplete { } // If the dropdown is closed, we'll open it - if (!this.dropdown.hasClass('open')) { + if (!this.dropdown.hasClass('show')) { this.loadingSuggestions = false; this.dropdownToggle.dropdown('toggle'); return this.searchInput.removeClass('disabled'); @@ -424,9 +424,9 @@ export default class SearchAutocomplete { } disableAutocomplete() { - if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) { + if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) { this.searchInput.addClass('disabled'); - this.dropdown.removeClass('open').trigger('hidden.bs.dropdown'); + this.dropdown.removeClass('show').trigger('hidden.bs.dropdown'); this.restoreMenu(); } } @@ -438,7 +438,7 @@ export default class SearchAutocomplete { } onClick(item, $el, e) { - if (location.pathname.indexOf(item.url) !== -1) { + if (window.location.pathname.indexOf(item.url) !== -1) { if (!e.metaKey) e.preventDefault(); if (!this.badgePresent) { if (item.category === 'Projects') { diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js index eecde4550f9..37b4a2a4c63 100644 --- a/app/assets/javascripts/settings_panels.js +++ b/app/assets/javascripts/settings_panels.js @@ -42,8 +42,8 @@ export default function initSettingsPanels() { } }); - if (location.hash) { - const $target = $(location.hash); + if (window.location.hash) { + const $target = $(window.location.hash); if ($target.length && $target.hasClass('settings')) { expandSection($target); } diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js index 2f974d6ff9d..8681a1776c6 100644 --- a/app/assets/javascripts/shared/milestones/form.js +++ b/app/assets/javascripts/shared/milestones/form.js @@ -6,5 +6,14 @@ import GLForm from '../../gl_form'; export default (initGFM = true) => { new ZenMode(); // eslint-disable-line no-new new DueDateSelectors(); // eslint-disable-line no-new - new GLForm($('.milestone-form'), initGFM); // eslint-disable-line no-new + // eslint-disable-next-line no-new + new GLForm($('.milestone-form'), { + emojis: true, + members: initGFM, + issues: initGFM, + mergeRequests: initGFM, + epics: initGFM, + milestones: initGFM, + labels: initGFM, + }); }; diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js index 1e246a56b85..8658081c6c2 100644 --- a/app/assets/javascripts/shortcuts_find_file.js +++ b/app/assets/javascripts/shortcuts_find_file.js @@ -13,8 +13,8 @@ export default class ShortcutsFindFile extends ShortcutsNavigation { element === this.projectFindFile.inputElement[0] && (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter') ) { - // when press up/down key in textbox, cusor prevent to move to home/end - event.preventDefault(); + // when press up/down key in textbox, cursor prevent to move to home/end + e.preventDefault(); return false; } diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 193788f754f..e9451be31fd 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -9,12 +9,10 @@ export default class ShortcutsIssuable extends Shortcuts { constructor(isMergeRequest) { super(); - this.$replyField = isMergeRequest ? $('.js-main-target-form #note_note') : $('.js-main-target-form .js-vue-comment-form'); - Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee')); Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone')); Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels')); - Mousetrap.bind('r', this.replyWithSelectedText.bind(this)); + Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText); Mousetrap.bind('e', ShortcutsIssuable.editIssue); if (isMergeRequest) { @@ -24,11 +22,16 @@ export default class ShortcutsIssuable extends Shortcuts { } } - replyWithSelectedText() { + static replyWithSelectedText() { + const $replyField = $('.js-main-target-form .js-vue-comment-form'); const documentFragment = window.gl.utils.getSelectedFragment(); + if (!$replyField.length) { + return false; + } + if (!documentFragment) { - this.$replyField.focus(); + $replyField.focus(); return false; } @@ -39,21 +42,22 @@ export default class ShortcutsIssuable extends Shortcuts { return false; } - const quote = _.map(selected.split('\n'), val => `${(`> ${val}`).trim()}\n`); + const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`); // If replyField already has some content, add a newline before our quote - const separator = (this.$replyField.val().trim() !== '' && '\n\n') || ''; - this.$replyField.val((a, current) => `${current}${separator}${quote.join('')}\n`) + const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; + $replyField + .val((a, current) => `${current}${separator}${quote.join('')}\n`) .trigger('input') .trigger('change'); // Trigger autosize const event = document.createEvent('Event'); event.initEvent('autosize:update', true, false); - this.$replyField.get(0).dispatchEvent(event); + $replyField.get(0).dispatchEvent(event); // Focus the input field - this.$replyField.focus(); + $replyField.focus(); return false; } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 5a374d84796..d22a1e1ac66 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -125,13 +125,13 @@ export default { <template> <div> <div - class="sidebar-collapsed-icon sidebar-collapsed-user" - :class="{ 'multiple-users': hasMoreThanOneAssignee }" v-tooltip + :class="{ 'multiple-users': hasMoreThanOneAssignee }" + :title="collapsedTooltipTitle" + class="sidebar-collapsed-icon sidebar-collapsed-user" data-container="body" data-placement="left" data-boundary="viewport" - :title="collapsedTooltipTitle" > <i v-if="hasNoUsers" @@ -140,17 +140,17 @@ export default { > </i> <button - type="button" - class="btn-link" v-for="(user, index) in users" v-if="shouldRenderCollapsedAssignee(index)" :key="user.id" + type="button" + class="btn-link" > <img - width="24" - class="avatar avatar-inline s24" :alt="assigneeAlt(user)" :src="avatarUrl(user)" + width="24" + class="avatar avatar-inline s24" /> <span class="author"> {{ user.name }} @@ -186,14 +186,14 @@ export default { </template> <template v-else-if="hasOneUser"> <a - class="author_link bold" :href="assigneeUrl(firstUser)" + class="author_link bold" > <img - width="32" - class="avatar avatar-inline s32" :alt="assigneeAlt(firstUser)" :src="avatarUrl(firstUser)" + width="32" + class="avatar avatar-inline s32" /> <span class="author"> {{ firstUser.name }} @@ -206,23 +206,23 @@ export default { <template v-else> <div class="user-list"> <div - class="user-item" v-for="(user, index) in users" v-if="renderAssignee(index)" :key="user.id" + class="user-item" > <a + :href="assigneeUrl(user)" + :data-title="user.name" class="user-link has-tooltip" data-container="body" data-placement="bottom" - :href="assigneeUrl(user)" - :data-title="user.name" > <img - width="32" - class="avatar avatar-inline s32" :alt="assigneeAlt(user)" :src="avatarUrl(user)" + width="32" + class="avatar avatar-inline s32" /> </a> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index b04a2eff798..123c92aff64 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -90,12 +90,12 @@ export default { /> <assignees v-if="!store.isFetching.assignees" - class="value" :root-path="store.rootPath" :users="store.assignees" :editable="store.editable" - @assign-self="assignSelf" :issuable-type="issuableType" + class="value" + @assign-self="assignSelf" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 3f6e2f05396..2b8d6207dea 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -54,7 +54,7 @@ export default { updateConfidentialAttribute(confidential) { this.service .update('issue', { confidential }) - .then(() => location.reload()) + .then(() => window.location.reload()) .catch(() => { Flash( __( @@ -70,13 +70,13 @@ export default { <template> <div class="block issuable-sidebar-item confidentiality"> <div - class="sidebar-collapsed-icon" - @click="toggleForm" v-tooltip + :title="tooltipLabel" + class="sidebar-collapsed-icon" data-container="body" data-placement="left" data-boundary="viewport" - :title="tooltipLabel" + @click="toggleForm" > <icon :name="confidentialityIcon" @@ -104,8 +104,8 @@ export default { v-if="!isConfidential" class="no-value sidebar-item-value"> <icon - name="eye" :size="16" + name="eye" aria-hidden="true" class="sidebar-item-icon inline" /> @@ -115,8 +115,8 @@ export default { v-else class="value sidebar-item-value hide-collapsed"> <icon - name="eye-slash" :size="16" + name="eye-slash" aria-hidden="true" class="sidebar-item-icon inline is-active" /> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index d392977e5e2..4906dad22e1 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -44,14 +44,14 @@ export default { <div class="dropdown show"> <div class="dropdown-menu sidebar-item-warning-message"> <p - class="text" v-if="isLocked" + class="text" v-html="unlockWarning"> </p> <p - class="text" v-else + class="text" v-html="lockWarning"> </p> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index fb69c741dcd..8bbc59f623a 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -76,7 +76,7 @@ export default { .update(this.issuableType, { discussion_locked: locked, }) - .then(() => location.reload()) + .then(() => window.location.reload()) .catch(() => Flash( this.__( @@ -94,13 +94,13 @@ export default { <template> <div class="block issuable-sidebar-item lock"> <div - class="sidebar-collapsed-icon" - @click="toggleForm" v-tooltip + :title="tooltipLabel" + class="sidebar-collapsed-icon" data-container="body" data-placement="left" data-boundary="viewport" - :title="tooltipLabel" + @click="toggleForm" > <icon :name="lockIcon" @@ -134,8 +134,8 @@ export default { class="value sidebar-item-value" > <icon - name="lock" :size="16" + name="lock" aria-hidden="true" class="sidebar-item-icon inline is-active" /> @@ -147,8 +147,8 @@ export default { class="no-value sidebar-item-value hide-collapsed" > <icon - name="lock-open" :size="16" + name="lock-open" aria-hidden="true" class="sidebar-item-icon inline" /> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 0a945fc7fd5..33dd6c981b6 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -80,12 +80,12 @@ <template> <div> <div - class="sidebar-collapsed-icon" v-tooltip + :title="participantLabel" + class="sidebar-collapsed-icon" data-container="body" data-placement="left" data-boundary="viewport" - :title="participantLabel" @click="onClickCollapsedIcon" > <i @@ -119,15 +119,15 @@ class="participants-author js-participants-author" > <a - class="author_link" :href="participant.web_url" + class="author_link" > <user-avatar-image :lazy="true" :img-src="participant.avatar_url" - css-classes="avatar-inline" :size="24" :tooltip-text="participant.name" + css-classes="avatar-inline" tooltip-placement="bottom" /> </a> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 6745c1aafff..448c8fc3602 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -97,9 +97,9 @@ </span> <toggle-button ref="toggleButton" - class="float-right hide-collapsed js-issuable-subscribe-button" :is-loading="showLoadingState" :value="subscribed" + class="float-right hide-collapsed js-issuable-subscribe-button" @change="toggleSubscription" /> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 209af1ce152..1d030c4f67f 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -110,12 +110,12 @@ <template> <div - class="sidebar-collapsed-icon" v-tooltip + :title="tooltipText" + class="sidebar-collapsed-icon" data-container="body" data-placement="left" data-boundary="viewport" - :title="tooltipText" > <icon name="timer" /> <div class="time-tracking-collapsed-summary"> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index 6f79310b1cc..d335c3f55af 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -1,8 +1,12 @@ <script> import { parseSeconds, stringifyTime } from '../../../lib/utils/pretty_time'; +import tooltip from '../../../vue_shared/directives/tooltip'; export default { name: 'TimeTrackingComparisonPane', + directives: { + tooltip, + }, props: { timeSpent: { type: Number, @@ -50,19 +54,17 @@ export default { <template> <div class="time-tracking-comparison-pane"> <div + v-tooltip + :title="timeRemainingTooltip" + :class="timeRemainingStatusClass" class="compare-meter" data-toggle="tooltip" data-placement="top" role="timeRemainingDisplay" - :aria-valuenow="timeRemainingTooltip" - :title="timeRemainingTooltip" - :data-original-title="timeRemainingTooltip" - :class="timeRemainingStatusClass" > <div - class="meter-container" - role="timeSpentPercent" :aria-valuenow="timeRemainingPercent" + class="meter-container" > <div :style="{ width: timeRemainingPercent }" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index 825063d9ba6..19ec0f05a26 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -45,8 +45,8 @@ export default { <p v-html="spendText"> </p> <a - class="btn btn-default learn-more-button" :href="href" + class="btn btn-default learn-more-button" > {{ __('Learn more') }} </a> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 7d56d2fa5ee..ca3b9338c29 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -101,8 +101,8 @@ export default { <template> <div - class="time_tracker time-tracking-component-wrap" v-cloak + class="time_tracker time-tracking-component-wrap" > <time-tracking-collapsed-state :show-comparison-state="showComparisonState" @@ -116,8 +116,8 @@ export default { <div class="title hide-collapsed"> {{ __('Time tracking') }} <div - class="help-button float-right" v-if="!showHelpState" + class="help-button float-right" @click="toggleHelpState(true)" > <i @@ -127,8 +127,8 @@ export default { </i> </div> <div - class="close-help-button float-right" v-if="showHelpState" + class="close-help-button float-right" @click="toggleHelpState(false)" > <i diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 3086e7d0fc9..655bf9198b7 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -75,7 +75,6 @@ function mountLockComponent(mediator) { function mountParticipantsComponent(mediator) { const el = document.querySelector('.js-sidebar-participants-entry-point'); - // eslint-disable-next-line no-new if (!el) return; // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index d86557e870a..d9ca5e46770 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -80,7 +80,7 @@ export default class SidebarMediator { return this.service.moveIssue(this.store.moveToProjectId) .then(response => response.json()) .then((data) => { - if (location.pathname !== data.web_url) { + if (window.location.pathname !== data.web_url) { visitUrl(data.web_url); } }); diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 1afff0dba38..99c93952e2a 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */ +/* eslint-disable func-names, prefer-arrow-callback, consistent-return, */ import $ from 'jquery'; import { __ } from './locale'; @@ -11,7 +11,7 @@ import syntaxHighlight from './syntax_highlight'; const WRAPPER = '<div class="diff-content"></div>'; const LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>'; const ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>'; -const COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <a class="click-to-expand">Click to expand it.</a></div>'; +const COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <button class="click-to-expand btn btn-link">Click to expand it.</button></div>'; export default class SingleFileDiff { constructor(file) { diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js index 77ab7c964e6..5e385400747 100644 --- a/app/assets/javascripts/smart_interval.js +++ b/app/assets/javascripts/smart_interval.js @@ -42,8 +42,7 @@ export default class SmartInterval { /* public */ start() { - const cfg = this.cfg; - const state = this.state; + const { cfg, state } = this; if (cfg.immediateExecution && !this.isLoading) { cfg.immediateExecution = false; @@ -100,7 +99,7 @@ export default class SmartInterval { /* private */ initInterval() { - const cfg = this.cfg; + const { cfg } = this; if (!cfg.lazyStart) { this.start(); @@ -151,7 +150,7 @@ export default class SmartInterval { } incrementInterval() { - const cfg = this.cfg; + const { cfg } = this; const currentInterval = this.getCurrentInterval(); if (cfg.hiddenInterval && !this.isPageVisible()) return; let nextInterval = currentInterval * cfg.incrementByFactorOf; @@ -166,7 +165,7 @@ export default class SmartInterval { isPageVisible() { return this.state.pageVisibility === 'visible'; } stopTimer() { - const state = this.state; + const { state } = this; state.intervalId = window.clearInterval(state.intervalId); } diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js index 81ec483f2d9..873a506a92f 100644 --- a/app/assets/javascripts/snippet/snippet_embed.js +++ b/app/assets/javascripts/snippet/snippet_embed.js @@ -1,5 +1,5 @@ export default () => { - const { protocol, host, pathname } = location; + const { protocol, host, pathname } = window.location; const shareBtn = document.querySelector('.js-share-btn'); const embedBtn = document.querySelector('.js-embed-btn'); const snippetUrlArea = document.querySelector('.js-snippet-url-area'); diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js index f52990ba232..37f3dd4b496 100644 --- a/app/assets/javascripts/syntax_highlight.js +++ b/app/assets/javascripts/syntax_highlight.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-else-return, prefer-arrow-callback, max-len */ +/* eslint-disable consistent-return, no-else-return */ import $ from 'jquery'; diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index e39213cb098..a5c18042ce7 100644 --- a/app/assets/javascripts/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -38,14 +38,14 @@ function simulateEvent(el, type, options = {}) { function isLast(target) { const el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; - const children = el.children; + const { children } = el; return children.length - 1 === target.index; } function getTarget(target) { const el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; - const children = el.children; + const { children } = el; return ( children[target.index] || diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index afbb958d058..85123a63a45 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, quotes, consistent-return, no-var, one-var, one-var-declaration-per-line, no-else-return, prefer-arrow-callback, class-methods-use-this */ +/* eslint-disable func-names, max-len, quotes, consistent-return, no-var, one-var, one-var-declaration-per-line, no-else-return, prefer-arrow-callback, class-methods-use-this */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js index 96af6d2fcca..78fd7ad441f 100644 --- a/app/assets/javascripts/u2f/authenticate.js +++ b/app/assets/javascripts/u2f/authenticate.js @@ -11,7 +11,6 @@ export default class U2FAuthenticate { constructor(container, form, u2fParams, fallbackButton, fallbackUI) { this.u2fUtils = null; this.container = container; - this.renderNotSupported = this.renderNotSupported.bind(this); this.renderAuthenticated = this.renderAuthenticated.bind(this); this.renderError = this.renderError.bind(this); this.renderInProgress = this.renderInProgress.bind(this); @@ -41,7 +40,6 @@ export default class U2FAuthenticate { this.signRequests = u2fParams.sign_requests.map(request => _(request).omit('challenge')); this.templates = { - notSupported: '#js-authenticate-u2f-not-supported', setup: '#js-authenticate-u2f-setup', inProgress: '#js-authenticate-u2f-in-progress', error: '#js-authenticate-u2f-error', @@ -55,7 +53,7 @@ export default class U2FAuthenticate { this.u2fUtils = utils; this.renderInProgress(); }) - .catch(() => this.renderNotSupported()); + .catch(() => this.switchToFallbackUI()); } authenticate() { @@ -96,10 +94,6 @@ export default class U2FAuthenticate { this.fallbackButton.classList.add('hidden'); } - renderNotSupported() { - return this.renderTemplate('notSupported'); - } - switchToFallbackUI() { this.fallbackButton.classList.add('hidden'); this.container[0].classList.add('hidden'); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index cd954f75613..e3d7645040d 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ +/* eslint-disable func-names, one-var, no-var, prefer-rest-params, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ /* global emitSidebarEvent */ @@ -250,7 +250,6 @@ function UsersSelect(currentUser, els, options = {}) { let anyUser; let index; - let j; let len; let name; let obj; @@ -259,7 +258,7 @@ function UsersSelect(currentUser, els, options = {}) { showDivider = 0; if (firstUser) { // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + for (index = 0, len = users.length; index < len; index += 1) { obj = users[index]; if (obj.username === firstUser) { users.splice(index, 1); @@ -501,7 +500,7 @@ function UsersSelect(currentUser, els, options = {}) { if (this.multiSelect) { selected = getSelected().find(u => user.id === u); - const fieldName = this.fieldName; + const { fieldName } = this; const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); if (field.length) { @@ -553,7 +552,7 @@ function UsersSelect(currentUser, els, options = {}) { minimumInputLength: 0, query: function(query) { return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; + var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; data = { results: users }; @@ -561,7 +560,8 @@ function UsersSelect(currentUser, els, options = {}) { if (firstUser) { // Move current user to the front of the list ref = data.results; - for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { + + for (index = 0, len = ref.length; index < len; index += 1) { obj = ref[index]; if (obj.username === firstUser) { data.results.splice(index, 1); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 1608858da22..5e464f8a0e2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -1,4 +1,5 @@ <script> +import Icon from '~/vue_shared/components/icon.vue'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import tooltip from '../../vue_shared/directives/tooltip'; import LoadingButton from '../../vue_shared/components/loading_button.vue'; @@ -14,6 +15,7 @@ export default { LoadingButton, MemoryUsage, StatusIcon, + Icon, }, directives: { tooltip, @@ -110,16 +112,15 @@ export default { class="deploy-link js-deploy-url" > {{ deployment.external_url_formatted }} - <i - class="fa fa-external-link" - aria-hidden="true" - > - </i> + <icon + :size="16" + name="external-link" + /> </a> </template> <span - v-if="hasDeploymentTime" v-tooltip + v-if="hasDeploymentTime" :title="deployment.deployed_at_formatted" class="js-deploy-time" > @@ -127,9 +128,9 @@ export default { </span> <loading-button v-if="deployment.stop_url" + :loading="isStopping" container-class="btn btn-default btn-sm prepend-left-default" label="Stop environment" - :loading="isStopping" @click="stopEnvironment" /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue index f012f9c6772..5e76f6b1cac 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue @@ -105,7 +105,7 @@ export default { MRWidgetService.fetchMetrics(this.metricsUrl) .then((res) => { if (res.status === statusCodes.NO_CONTENT) { - this.backOffRequestCounter = this.backOffRequestCounter += 1; + this.backOffRequestCounter += 1; /* eslint-disable no-unused-expressions */ this.backOffRequestCounter < 3 ? next() : stop(res); } else { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index 8338fde61c7..22c2f74f900 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -35,17 +35,17 @@ <template> <a :href="authorUrl" - class="author-link inline" :v-tooltip="showAuthorTooltip" :title="author.name" + class="author-link inline" > <img :src="avatarUrl" class="avatar avatar-inline s16" /> <span - class="author" v-if="showAuthorName" + class="author" > {{ author.name }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue index 644e4b7d81a..ba16cb9e2c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.vue @@ -1,11 +1,15 @@ <script> + import tooltip from '~/vue_shared/directives/tooltip'; import MrWidgetAuthor from './mr_widget_author.vue'; export default { - name: 'MRWidgetAuthorTime', + name: 'MrWidgetAuthorTime', components: { MrWidgetAuthor, }, + directives: { + tooltip, + }, props: { actionText: { type: String, @@ -31,9 +35,8 @@ {{ actionText }} <mr-widget-author :author="author" /> <time + v-tooltip :title="dateTitle" - data-toggle="tooltip" - data-placement="top" data-container="body" > {{ dateReadable }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 51a0fda6555..3ce9d8dc26a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -59,11 +59,11 @@ export default { <strong> {{ s__("mrWidget|Request to merge") }} <span - class="label-branch js-source-branch" :class="{ 'label-truncated': isSourceBranchLong }" :title="isSourceBranchLong ? mr.sourceBranch : ''" - data-placement="bottom" :v-tooltip="isSourceBranchLong" + class="label-branch js-source-branch" + data-placement="bottom" v-html="mr.sourceBranchLink" > </span> @@ -77,10 +77,10 @@ export default { {{ s__("mrWidget|into") }} <span - class="label-branch" :v-tooltip="isTargetBranchLong" :class="{ 'label-truncatedtooltip': isTargetBranchLong }" :title="isTargetBranchLong ? mr.targetBranch : ''" + class="label-branch" data-placement="bottom" > <a @@ -108,9 +108,9 @@ export default { {{ s__("mrWidget|Web IDE") }} </a> <button + :disabled="mr.sourceBranchRemoved" data-target="#modal_merge_info" data-toggle="modal" - :disabled="mr.sourceBranchRemoved" class="btn btn-sm btn-default inline js-check-out-branch" type="button" > @@ -134,8 +134,8 @@ export default { <ul class="dropdown-menu dropdown-menu-right"> <li> <a - class="js-download-email-patches" :href="mr.emailPatchesPath" + class="js-download-email-patches" download > {{ s__("mrWidget|Email patches") }} @@ -143,8 +143,8 @@ export default { </li> <li> <a - class="js-download-plain-diff" :href="mr.plainDiffPath" + class="js-download-plain-diff" download > {{ s__("mrWidget|Plain diff") }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 48dff8c4916..2f0b5e12c12 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -67,8 +67,8 @@ export default { </template> <template v-else-if="hasPipeline"> <a - class="append-right-10" :href="status.details_path" + class="append-right-10" > <ci-icon :status="status" /> </a> @@ -96,8 +96,8 @@ export default { <span class="mr-widget-pipeline-graph"> <span - class="stage-cell" v-if="hasStages" + class="stage-cell" > <div v-for="(stage, i) in pipeline.details.stages" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 1fdc3218671..53c4dc8c8f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -32,7 +32,7 @@ }; </script> <template> - <div class="space-children flex-container-block append-right-10"> + <div class="space-children d-flex append-right-10"> <div v-if="isLoading" class="mr-widget-icon" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index 460437ceeff..56879c04d16 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -25,9 +25,9 @@ </span> <i v-tooltip - class="fa fa-question-circle" :title="tooltipTitle" :aria-label="tooltipTitle" + class="fa fa-question-circle" > </i> </p> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index b404a592234..2133124347c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -39,10 +39,10 @@ {{ s__("mrWidget|This merge request failed to be merged automatically") }} </span> <button - @click="refreshWidget" :disabled="isRefreshing" type="button" class="btn btn-sm btn-default" + @click="refreshWidget" > <loading-icon v-if="isRefreshing" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index caeaac75b45..ae6630dcd6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -11,8 +11,8 @@ <template> <div class="mr-widget-body media"> <status-icon - status="loading" :show-disabled-button="true" + status="loading" /> <div class="media-body space-children"> <span class="bold"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 68b691fc914..25a57e520ee 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,11 +1,11 @@ <script> - import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; + import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; export default { name: 'MRWidgetClosed', components: { - mrWidgetAuthorTime, + MrWidgetAuthorTime, statusIcon, }, props: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 5c1500ab801..dff9ec657b9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -20,8 +20,8 @@ <template> <div class="mr-widget-body media"> <status-icon - status="warning" :show-disabled-button="true" + status="warning" /> <div class="media-body space-children"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 05fecd4de35..c302960f16e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -1,5 +1,6 @@ <script> import { n__ } from '~/locale'; +import { stripHtml } from '~/lib/utils/text_utility'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; @@ -27,6 +28,9 @@ export default { }, computed: { + mergeError() { + return this.mr.mergeError ? stripHtml(this.mr.mergeError, ' ').trim() : ''; + }, timerText() { return n__( 'Refreshing in a second to show the updated status...', @@ -76,16 +80,16 @@ export default { </template> <template v-else> <status-icon - status="warning" :show-disabled-button="true" + status="warning" /> <div class="media-body space-children"> <span class="bold"> <span - class="has-error-message" v-if="mr.mergeError" + class="has-error-message" > - {{ mr.mergeError }}. + {{ mergeError }}. </span> <span v-else> {{ s__("mrWidget|Merge failed.") }} @@ -97,9 +101,9 @@ export default { </span> </span> <button - @click="refresh" class="btn btn-default btn-sm js-refresh-button" type="button" + @click="refresh" > {{ s__("mrWidget|Refresh now") }} </button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue index e8352c362d6..97f4196b94d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue @@ -82,7 +82,7 @@ <div class="mr-widget-body media"> <status-icon status="success" /> <div class="media-body"> - <h4 class="flex-container-block"> + <h4 class="d-flex align-items-start"> <span class="append-right-10"> {{ s__("mrWidget|Set by") }} <mr-widget-author :author="mr.setToMWPSBy" /> @@ -90,11 +90,11 @@ </span> <a v-if="mr.canCancelAutomaticMerge" - @click.prevent="cancelAutomaticMerge" :disabled="isCancellingAutoMerge" role="button" href="#" - class="btn btn-sm btn-default js-cancel-auto-merge"> + class="btn btn-sm btn-default js-cancel-auto-merge" + @click.prevent="cancelAutomaticMerge"> <i v-if="isCancellingAutoMerge" class="fa fa-spinner fa-spin" @@ -119,7 +119,7 @@ </p> <p v-else - class="flex-container-block" + class="d-flex align-items-start" > <span class="append-right-10"> {{ s__("mrWidget|The source branch will not be removed") }} @@ -127,10 +127,10 @@ <a v-if="canRemoveSourceBranch" :disabled="isRemovingSourceBranch" - @click.prevent="removeSourceBranch" role="button" class="btn btn-sm btn-default js-remove-source-branch" href="#" + @click.prevent="removeSourceBranch" > <i v-if="isRemovingSourceBranch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index eb581b807a2..1a444c04a1d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -4,7 +4,7 @@ import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import { s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - import mrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; + import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; @@ -14,7 +14,7 @@ tooltip, }, components: { - mrWidgetAuthorTime, + MrWidgetAuthorTime, loadingIcon, statusIcon, ClipboardButton, @@ -116,44 +116,44 @@ :date-readable="mr.metrics.readableMergedAt" /> <a - v-if="mr.canRevertInCurrentMR" v-tooltip + v-if="mr.canRevertInCurrentMR" + :title="revertTitle" class="btn btn-close btn-sm" href="#modal-revert-commit" data-toggle="modal" data-container="body" - :title="revertTitle" > {{ revertLabel }} </a> <a - v-else-if="mr.revertInForkPath" v-tooltip - class="btn btn-close btn-sm" - data-method="post" + v-else-if="mr.revertInForkPath" :href="mr.revertInForkPath" :title="revertTitle" + class="btn btn-close btn-sm" + data-method="post" > {{ revertLabel }} </a> <a - v-if="mr.canCherryPickInCurrentMR" v-tooltip + v-if="mr.canCherryPickInCurrentMR" + :title="cherryPickTitle" class="btn btn-default btn-sm" href="#modal-cherry-pick-commit" data-toggle="modal" data-container="body" - :title="cherryPickTitle" > {{ cherryPickLabel }} </a> <a - v-else-if="mr.cherryPickInForkPath" v-tooltip - class="btn btn-default btn-sm" - data-method="post" + v-else-if="mr.cherryPickInForkPath" :href="mr.cherryPickInForkPath" :title="cherryPickTitle" + class="btn btn-default btn-sm" + data-method="post" > {{ cherryPickLabel }} </a> @@ -173,7 +173,7 @@ </a> <clipboard-button :title="__('Copy commit SHA to clipboard')" - :text="mr.shortMergeCommitSha" + :text="mr.mergeCommitSha" css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha" /> </p> @@ -186,10 +186,10 @@ > <span>{{ s__("mrWidget|You can remove source branch now") }}</span> <button - @click="removeSourceBranch" :disabled="isMakingRequest" type="button" class="btn btn-sm btn-default js-remove-branch-button" + @click="removeSourceBranch" > {{ s__("mrWidget|Remove Source Branch") }} </button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index 718c0e4b3c6..b0e96f74626 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -39,8 +39,8 @@ <template> <div class="mr-widget-body media"> <status-icon - status="warning" :show-disabled-button="true" + status="warning" /> <div class="media-body space-children"> @@ -51,9 +51,9 @@ {{ missingBranchNameMessage }} <i v-tooltip - class="fa fa-question-circle" :title="message" :aria-label="message" + class="fa fa-question-circle" > </i> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue index e4af50b09f8..92eee2cf5dd 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue @@ -12,8 +12,8 @@ <template> <div class="mr-widget-body media"> <status-icon - status="success" :show-disabled-button="true" + status="success" /> <div class="media-body space-children"> <span class="bold"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue index 6d7cc03f7ad..37ee5215cea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -11,8 +11,8 @@ <template> <div class="mr-widget-body media"> <status-icon - status="warning" :show-disabled-button="true" + status="warning" /> <div class="media-body space-children"> <span class="bold"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 143fd328d88..2d8c3d6be87 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -110,9 +110,9 @@ js-toggle-container accept-action media space-children" > <button + :disabled="isMakingRequest" type="button" class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button" - :disabled="isMakingRequest" @click="rebase" > <loading-icon v-if="isMakingRequest" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js new file mode 100644 index 00000000000..bf8628d18a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js @@ -0,0 +1,15 @@ +/* +The squash-before-merge button is EE only, but it's located right in the middle +of the readyToMerge state component template. + +If we didn't declare this component in CE, we'd need to maintain a separate copy +of the readyToMergeState template in EE, which is pretty big and likely to change. + +Instead, in CE, we declare the component, but it's hidden and is configured to do nothing. +In EE, the configuration extends this object to add a functioning squash-before-merge +button. +*/ + +export default { + template: '', +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.vue index 875c3323dbb..25c1044fe2b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.vue @@ -37,23 +37,23 @@ export default { <div class="accept-control inline"> <label class="merge-param-checkbox"> <input + :disabled="isMergeButtonDisabled" + v-model="squashBeforeMerge" type="checkbox" name="squash" class="qa-squash-checkbox" - :disabled="isMergeButtonDisabled" - v-model="squashBeforeMerge" @change="updateSquashModel" /> {{ __('Squash commits') }} </label> <a + v-tooltip :href="mr.squashBeforeMergeHelpPath" data-title="About this feature" data-placement="bottom" target="_blank" rel="noopener noreferrer nofollow" data-container="body" - v-tooltip > <icon name="question-o" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue index 8d55477929f..2bb1a34412e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue @@ -12,8 +12,8 @@ export default { <template> <div class="mr-widget-body media"> <status-icon - status="warning" :show-disabled-button="true" + status="warning" /> <div class="media-body space-children"> <span class="bold"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 3a194320bd8..fe777a07189 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -235,11 +235,11 @@ export default { <div class="mr-widget-body-controls media space-children"> <span class="btn-group append-bottom-5"> <button - @click="handleMergeButtonClick()" :disabled="isMergeButtonDisabled" :class="mergeButtonClass" type="button" - class="qa-merge-button"> + class="qa-merge-button" + @click="handleMergeButtonClick()"> <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" @@ -265,28 +265,28 @@ export default { role="menu"> <li> <a - @click.prevent="handleMergeButtonClick(true)" class="merge_when_pipeline_succeeds" - href="#"> + href="#" + @click.prevent="handleMergeButtonClick(true)"> <span class="media"> <span - v-html="successSvg" class="merge-opt-icon" - aria-hidden="true"></span> + aria-hidden="true" + v-html="successSvg"></span> <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> </span> </a> </li> <li> <a - @click.prevent="handleMergeButtonClick(false, true)" class="accept-merge-request" - href="#"> + href="#" + @click.prevent="handleMergeButtonClick(false, true)"> <span class="media"> <span - v-html="warningSvg" class="merge-opt-icon" - aria-hidden="true"></span> + aria-hidden="true" + v-html="warningSvg"></span> <span class="media-body merge-opt-title">Merge immediately</span> </span> </a> @@ -299,8 +299,8 @@ export default { <input id="remove-source-branch-input" v-model="removeSourceBranch" - class="js-remove-source-branch-checkbox" :disabled="isRemoveSourceBranchButtonDisabled" + class="js-remove-source-branch-checkbox" type="checkbox"/> Remove source branch </label> @@ -317,10 +317,10 @@ export default { </span> <button v-else - @click="toggleCommitMessageEditor" :disabled="isMergeButtonDisabled" class="js-modify-commit-message-button btn btn-default btn-sm" - type="button"> + type="button" + @click="toggleCommitMessageEditor"> Modify commit message </button> </template> @@ -356,8 +356,8 @@ export default { </p> <div class="hint"> <a - @click.prevent="updateCommitMessage" href="#" + @click.prevent="updateCommitMessage" > {{ commitMessageLinkTitle }} </a> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 7cc07401911..16c903c923f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -12,8 +12,8 @@ export default { <template> <div class="mr-widget-body media"> <status-icon - status="warning" :show-disabled-button="true" + status="warning" /> <div class="media-body space-children"> <span class="bold"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 8ea3f22ecc2..5eb2058a03b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -18,8 +18,8 @@ export default { <template> <div class="mr-widget-body media"> <status-icon - status="warning" :show-disabled-button="true" + status="warning" /> <div class="media-body space-children"> <span class="bold"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index fe2608e8212..89c9a41f316 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -43,8 +43,8 @@ export default { <template> <div class="mr-widget-body media"> <status-icon - status="warning" :show-disabled-button="Boolean(mr.removeWIPPath)" + status="warning" /> <div class="media-body space-children"> <span class="bold"> @@ -60,10 +60,10 @@ export default { </span> <button v-if="mr.removeWIPPath" - @click="removeWIP" :disabled="isMakingRequest" type="button" - class="btn btn-default btn-xs js-remove-wip"> + class="btn btn-default btn-sm js-remove-wip" + @click="removeWIP"> <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index f69fe03fcb3..09477da40b5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -36,7 +36,7 @@ import { notify, SourceBranchRemovalStatus, } from './dependencies'; -import { setFavicon } from '../lib/utils/common_utils'; +import { setFaviconOverlay } from '../lib/utils/common_utils'; export default { el: '#js-vue-mr-widget', @@ -159,8 +159,9 @@ export default { }, setFaviconHelper() { if (this.mr.ciStatusFaviconPath) { - setFavicon(this.mr.ciStatusFaviconPath); + return setFaviconOverlay(this.mr.ciStatusFaviconPath); } + return Promise.resolve(); }, fetchDeployments() { return this.service.fetchDeployments() @@ -171,7 +172,7 @@ export default { } }) .catch(() => { - createFlash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line + createFlash('Something went wrong while fetching the environments for this merge request. Please try again.'); }); }, fetchActionsContent() { @@ -190,7 +191,7 @@ export default { if (data.ci_status === this.mr.ciStatus) return; if (!data.pipeline) return; - const label = data.pipeline.details.status.label; + const { label } = data.pipeline.details.status; const title = `Pipeline ${label}`; const message = `Pipeline ${label} for "${data.title}"`; @@ -210,7 +211,7 @@ export default { // `params` should be an Array contains a Boolean, like `[true]` // Passing parameter as Boolean didn't work. eventHub.$on('SetBranchRemoveFlag', (params) => { - this.mr.isRemovingSourceBranch = params[0]; + [this.mr.isRemovingSourceBranch] = params; }); eventHub.$on('FailedToMerge', (mergeError) => { @@ -265,10 +266,10 @@ export default { /> <section - v-if="mr.maintainerEditAllowed" + v-if="mr.allowCollaboration" class="mr-info-list mr-links" > - {{ s__("mrWidget|Allows edits from maintainers") }} + {{ s__("mrWidget|Allows commits from members who can merge to the target branch") }} </section> <mr-widget-related-links @@ -282,8 +283,8 @@ export default { /> </div> <div - class="mr-widget-footer" v-if="shouldRenderMergeHelp" + class="mr-widget-footer" > <mr-widget-merge-help /> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index e5b7e1f1c68..c881cd496d1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -26,6 +26,7 @@ export default class MergeRequestStore { this.mergeStatus = data.merge_status; this.commitMessage = data.merge_commit_message; this.shortMergeCommitSha = data.short_merge_commit_sha; + this.mergeCommitSha = data.merge_commit_sha; this.commitMessageWithDescription = data.merge_commit_message_with_description; this.commitsCount = data.commits_count; this.divergedCommitsCount = data.diverged_commits_count; @@ -83,7 +84,7 @@ export default class MergeRequestStore { this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; - this.maintainerEditAllowed = data.allow_maintainer_to_push; + this.allowCollaboration = data.allow_collaboration; // Cherry-pick and Revert actions related this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 0d64efcbf68..a2518e2a611 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -50,9 +50,9 @@ export default { </script> <template> <a + v-tooltip :href="status.details_path" :class="cssClass" - v-tooltip :title="!showText ? status.text : ''" > <ci-icon :status="status" /> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index cb2cc3901ad..dc5760bce28 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -49,14 +49,14 @@ export default { <template> <button - type="button" - class="btn" + v-tooltip :class="cssClass" :title="title" :data-clipboard-text="text" - v-tooltip :data-container="tooltipContainer" :data-placement="tooltipPlacement" + type="button" + class="btn" > <i aria-hidden="true" diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 8f250a6c989..13bca99dcb3 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -124,11 +124,11 @@ export default { </div> <a - class="ref-name" - :href="commitRef.ref_url" v-tooltip - data-container="body" + :href="commitRef.ref_url" :title="commitRef.name" + class="ref-name" + data-container="body" > {{ commitRef.name }} </a> @@ -139,8 +139,8 @@ export default { /> <a - class="commit-sha" :href="commitUrl" + class="commit-sha" > {{ shortSha }} </a> @@ -152,15 +152,15 @@ export default { > <user-avatar-link v-if="hasAuthor" - class="avatar-image-container" :link-href="author.path" :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" + class="avatar-image-container" /> <a - class="commit-row-message" :href="commitUrl" + class="commit-row-message" > {{ title }} </a> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index 7b5367ac19b..f1ef50d0e3d 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -32,7 +32,10 @@ export default { <div class="file-container"> <div class="file-content"> <p class="prepend-top-10 file-info"> - {{ fileName }} ({{ fileSizeReadable }}) + {{ fileName }} + <template v-if="fileSize > 0"> + ({{ fileSizeReadable }}) + </template> </p> <a :href="path" @@ -41,9 +44,9 @@ export default { download target="_blank"> <icon + :size="16" name="download" css-classes="float-left append-right-8" - :size="16" /> {{ __('Download') }} </a> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index a5999f909ca..133bdbb54f7 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import { numberToHumanSize } from '../../../../lib/utils/number_utils'; export default { @@ -12,6 +13,10 @@ export default { required: false, default: 0, }, + renderInfo: { + type: Boolean, + default: true, + }, }, data() { return { @@ -26,14 +31,34 @@ export default { return numberToHumanSize(this.fileSize); }, }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + }, + mounted() { + // The onImgLoad may have happened before the control was actually mounted + this.onImgLoad(); + this.resizeThrottled = _.throttle(this.onImgLoad, 400); + window.addEventListener('resize', this.resizeThrottled, false); + }, methods: { onImgLoad() { - const contentImg = this.$refs.contentImg; - this.isZoomable = - contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height; + const { contentImg } = this.$refs; + + if (contentImg) { + this.isZoomable = + contentImg.naturalWidth > contentImg.width || + contentImg.naturalHeight > contentImg.height; + + this.width = contentImg.naturalWidth; + this.height = contentImg.naturalHeight; - this.width = contentImg.naturalWidth; - this.height = contentImg.naturalHeight; + this.$emit('imgLoaded', { + width: this.width, + height: this.height, + renderedWidth: contentImg.clientWidth, + renderedHeight: contentImg.clientHeight, + }); + } }, onImgClick() { if (this.isZoomable) this.isZoomed = !this.isZoomed; @@ -47,20 +72,22 @@ export default { <div class="file-content image_file"> <img ref="contentImg" - :class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }" + :class="{ 'is-zoomable': isZoomable, 'is-zoomed': isZoomed }" :src="path" :alt="path" @load="onImgLoad" @click="onImgClick"/> - <p class="file-info prepend-top-10"> + <p + v-if="renderInfo" + class="file-info prepend-top-10"> <template v-if="fileSize>0"> {{ fileSizeReadable }} </template> <template v-if="fileSize>0 && width && height"> - - + | </template> <template v-if="width && height"> - {{ width }} x {{ height }} + W: {{ width }} | H: {{ height }} </template> </p> </div> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 09e0094054d..a10deb93f0f 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -4,7 +4,7 @@ import { __ } from '~/locale'; import $ from 'jquery'; import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; -const CancelToken = axios.CancelToken; +const { CancelToken } = axios; let axiosSource; export default { diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue index 424af5a0293..9c1e5c68649 100644 --- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue +++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue @@ -86,8 +86,8 @@ <div class="modal-open"> <div :id="id" - class="modal" :class="id ? '' : 'd-block'" + class="modal" role="dialog" tabindex="-1" > @@ -105,9 +105,9 @@ <button type="button" class="close float-right" - @click="emitCancel($event)" data-dismiss="modal" aria-label="Close" + @click="emitCancel($event)" > <span aria-hidden="true">×</span> </button> @@ -115,22 +115,22 @@ </div> <div class="modal-body"> <slot - name="body" :text="text" + name="body" > <p>{{ text }}</p> </slot> </div> <div - class="modal-footer" v-if="!hideFooter" + class="modal-footer" > <button + :class="btnCancelKindClass" type="button" class="btn" - :class="btnCancelKindClass" - @click="emitCancel($event)" data-dismiss="modal" + @click="emitCancel($event)" > {{ closeButtonLabel }} </button> @@ -151,12 +151,12 @@ <button v-if="primaryButtonLabel" - type="button" - class="btn js-primary-button" :disabled="submitDisabled" :class="btnKindClass" - @click="emitSubmit($event)" + type="button" + class="btn js-primary-button" data-dismiss="modal" + @click="emitSubmit($event)" > {{ primaryButtonLabel }} </button> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/constants.js b/app/assets/javascripts/vue_shared/components/diff_viewer/constants.js new file mode 100644 index 00000000000..6c1840361af --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/constants.js @@ -0,0 +1,12 @@ +export const diffModes = { + replaced: 'replaced', + new: 'new', + deleted: 'deleted', + renamed: 'renamed', +}; + +export const imageViewMode = { + twoup: 'twoup', + swipe: 'swipe', + onion: 'onion', +}; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue new file mode 100644 index 00000000000..d3cbe3c7e74 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -0,0 +1,74 @@ +<script> +import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils'; +import ImageDiffViewer from './viewers/image_diff_viewer.vue'; +import DownloadDiffViewer from './viewers/download_diff_viewer.vue'; + +export default { + props: { + diffMode: { + type: String, + required: true, + }, + newPath: { + type: String, + required: true, + }, + newSha: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + oldSha: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + viewer() { + if (!this.newPath) return null; + + const previewInfo = viewerInformationForPath(this.newPath); + if (!previewInfo) return DownloadDiffViewer; + + switch (previewInfo.id) { + case 'image': + return ImageDiffViewer; + default: + return DownloadDiffViewer; + } + }, + basePath() { + // We might get the project path from rails with the relative url already setup + return this.projectPath.indexOf('/') === 0 ? '' : `${gon.relative_url_root}/`; + }, + fullOldPath() { + return `${this.basePath}${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`; + }, + fullNewPath() { + return `${this.basePath}${this.projectPath}/raw/${this.newSha}/${this.newPath}`; + }, + }, +}; +</script> + +<template> + <div + v-if="viewer" + class="diff-file preview-container"> + <component + :is="viewer" + :diff-mode="diffMode" + :new-path="fullNewPath" + :old-path="fullOldPath" + :project-path="projectPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/download_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/download_diff_viewer.vue new file mode 100644 index 00000000000..50389b6ae63 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/download_diff_viewer.vue @@ -0,0 +1,69 @@ +<script> +import DownloadViewer from '../../content_viewer/viewers/download_viewer.vue'; +import { diffModes } from '../constants'; + +export default { + components: { + DownloadViewer, + }, + props: { + diffMode: { + type: String, + required: true, + }, + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + diffModes, +}; +</script> + +<template> + <div class="diff-file-container"> + <div class="diff-viewer"> + <div + v-if="diffMode === $options.diffModes.replaced" + class="two-up view row"> + <div class="col-sm-6 deleted"> + <download-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + <div class="col-sm-6 added"> + <download-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + </div> + <div + v-else-if="diffMode === $options.diffModes.new" + class="added"> + <download-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + <div + v-else + class="deleted"> + <download-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue new file mode 100644 index 00000000000..38e881d17a2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -0,0 +1,160 @@ +<script> +import { pixeliseValue } from '../../../lib/utils/dom_utils'; +import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue'; + +export default { + components: { + ImageViewer, + }, + props: { + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + onionMaxWidth: undefined, + onionMaxHeight: undefined, + onionOldImgInfo: null, + onionNewImgInfo: null, + onionDraggerPos: 0, + onionOpacity: 1, + dragging: false, + }; + }, + computed: { + onionMaxPixelWidth() { + return pixeliseValue(this.onionMaxWidth); + }, + onionMaxPixelHeight() { + return pixeliseValue(this.onionMaxHeight); + }, + onionDraggerPixelPos() { + return pixeliseValue(this.onionDraggerPos); + }, + }, + beforeDestroy() { + document.body.removeEventListener('mouseup', this.stopDrag); + this.$refs.dragger.removeEventListener('mousedown', this.startDrag); + }, + methods: { + dragMove(e) { + if (!this.dragging) return; + const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left; + const dragTrackWidth = + this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; + + let leftValue = left; + if (leftValue < 0) leftValue = 0; + if (leftValue > dragTrackWidth) leftValue = dragTrackWidth; + + this.onionOpacity = left / dragTrackWidth; + this.onionDraggerPos = leftValue; + }, + startDrag() { + this.dragging = true; + document.body.style.userSelect = 'none'; + document.body.addEventListener('mousemove', this.dragMove); + }, + stopDrag() { + this.dragging = false; + document.body.style.userSelect = ''; + document.body.removeEventListener('mousemove', this.dragMove); + }, + prepareOnionSkin() { + if (this.onionOldImgInfo && this.onionNewImgInfo) { + this.onionMaxWidth = Math.max( + this.onionOldImgInfo.renderedWidth, + this.onionNewImgInfo.renderedWidth, + ); + this.onionMaxHeight = Math.max( + this.onionOldImgInfo.renderedHeight, + this.onionNewImgInfo.renderedHeight, + ); + + this.onionOpacity = 1; + this.onionDraggerPos = + this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; + + document.body.addEventListener('mouseup', this.stopDrag); + } + }, + onionNewImgLoaded(imgInfo) { + this.onionNewImgInfo = imgInfo; + this.prepareOnionSkin(); + }, + onionOldImgLoaded(imgInfo) { + this.onionOldImgInfo = imgInfo; + this.prepareOnionSkin(); + }, + }, +}; +</script> + +<template> + <div class="onion-skin view"> + <div + :style="{ + 'width': onionMaxPixelWidth, + 'height': onionMaxPixelHeight, + 'user-select': dragging === true ? 'none' : '', + }" + class="onion-skin-frame"> + <div + :style="{ + 'width': onionMaxPixelWidth, + 'height': onionMaxPixelHeight, + }" + class="frame deleted"> + <image-viewer + key="onionOldImg" + :render-info="false" + :path="oldPath" + :project-path="projectPath" + @imgLoaded="onionOldImgLoaded" + /> + </div> + <div + ref="addedFrame" + :style="{ + 'opacity': onionOpacity, + 'width': onionMaxPixelWidth, + 'height': onionMaxPixelHeight, + }" + class="added frame"> + <image-viewer + key="onionNewImg" + :render-info="false" + :path="newPath" + :project-path="projectPath" + @imgLoaded="onionNewImgLoaded" + /> + </div> + <div class="controls"> + <div class="transparent"></div> + <div + ref="dragTrack" + class="drag-track" + @mousedown="startDrag" + @mouseup="stopDrag"> + <div + ref="dragger" + :style="{ 'left': onionDraggerPixelPos }" + class="dragger"> + </div> + </div> + <div class="opaque"></div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue new file mode 100644 index 00000000000..86366c799a2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -0,0 +1,158 @@ +<script> +import _ from 'underscore'; +import { pixeliseValue } from '../../../lib/utils/dom_utils'; +import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue'; + +export default { + components: { + ImageViewer, + }, + props: { + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + dragging: false, + swipeOldImgInfo: null, + swipeNewImgInfo: null, + swipeMaxWidth: undefined, + swipeMaxHeight: undefined, + swipeBarPos: 1, + swipeWrapWidth: undefined, + }; + }, + computed: { + swipeMaxPixelWidth() { + return pixeliseValue(this.swipeMaxWidth); + }, + swipeMaxPixelHeight() { + return pixeliseValue(this.swipeMaxHeight); + }, + swipeWrapPixelWidth() { + return pixeliseValue(this.swipeWrapWidth); + }, + swipeBarPixelPos() { + return pixeliseValue(this.swipeBarPos); + }, + }, + beforeDestroy() { + window.removeEventListener('resize', this.resizeThrottled, false); + document.body.removeEventListener('mouseup', this.stopDrag); + document.body.removeEventListener('mousemove', this.dragMove); + }, + mounted() { + window.addEventListener('resize', this.resize, false); + }, + methods: { + dragMove(e) { + if (!this.dragging) return; + + let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left; + const spaceLeft = 20; + const { clientWidth } = this.$refs.swipeFrame; + if (leftValue <= 0) { + leftValue = 0; + } else if (leftValue > clientWidth - spaceLeft) { + leftValue = clientWidth - spaceLeft; + } + + this.swipeWrapWidth = this.swipeMaxWidth - leftValue; + this.swipeBarPos = leftValue; + }, + startDrag() { + this.dragging = true; + document.body.style.userSelect = 'none'; + document.body.addEventListener('mousemove', this.dragMove); + }, + stopDrag() { + this.dragging = false; + document.body.style.userSelect = ''; + document.body.removeEventListener('mousemove', this.dragMove); + }, + prepareSwipe() { + if (this.swipeOldImgInfo && this.swipeNewImgInfo) { + // Add 2 for border width + this.swipeMaxWidth = + Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2; + this.swipeWrapWidth = this.swipeMaxWidth; + this.swipeMaxHeight = + Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2; + + document.body.addEventListener('mouseup', this.stopDrag); + } + }, + swipeNewImgLoaded(imgInfo) { + this.swipeNewImgInfo = imgInfo; + this.prepareSwipe(); + }, + swipeOldImgLoaded(imgInfo) { + this.swipeOldImgInfo = imgInfo; + this.prepareSwipe(); + }, + resize: _.throttle(function throttledResize() { + this.swipeBarPos = 0; + }, 400), + }, +}; +</script> + +<template> + <div class="swipe view"> + <div + ref="swipeFrame" + :style="{ + 'width': swipeMaxPixelWidth, + 'height': swipeMaxPixelHeight, + }" + class="swipe-frame"> + <div class="frame deleted"> + <image-viewer + key="swipeOldImg" + ref="swipeOldImg" + :render-info="false" + :path="oldPath" + :project-path="projectPath" + @imgLoaded="swipeOldImgLoaded" + /> + </div> + <div + ref="swipeWrap" + :style="{ + 'width': swipeWrapPixelWidth, + 'height': swipeMaxPixelHeight, + }" + class="swipe-wrap"> + <div class="frame added"> + <image-viewer + key="swipeNewImg" + :render-info="false" + :path="newPath" + :project-path="projectPath" + @imgLoaded="swipeNewImgLoaded" + /> + </div> + </div> + <span + ref="swipeBar" + :style="{ 'left': swipeBarPixelPos }" + class="swipe-bar" + @mousedown="startDrag" + @mouseup="stopDrag"> + <span class="top-handle"></span> + <span class="bottom-handle"></span> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue new file mode 100644 index 00000000000..9c19266ecdf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -0,0 +1,41 @@ +<script> +import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue'; + +export default { + components: { + ImageViewer, + }, + props: { + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div class="two-up view row"> + <div class="col-sm-6 frame deleted"> + <image-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + <div class="col-sm-6 frame added"> + <image-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue new file mode 100644 index 00000000000..1af85283277 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -0,0 +1,109 @@ +<script> +import ImageViewer from '../../content_viewer/viewers/image_viewer.vue'; +import TwoUpViewer from './image_diff/two_up_viewer.vue'; +import SwipeViewer from './image_diff/swipe_viewer.vue'; +import OnionSkinViewer from './image_diff/onion_skin_viewer.vue'; +import { diffModes, imageViewMode } from '../constants'; + +export default { + components: { + ImageViewer, + TwoUpViewer, + SwipeViewer, + OnionSkinViewer, + }, + props: { + diffMode: { + type: String, + required: true, + }, + newPath: { + type: String, + required: true, + }, + oldPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + mode: imageViewMode.twoup, + }; + }, + methods: { + changeMode(newMode) { + this.mode = newMode; + }, + }, + diffModes, + imageViewMode, +}; +</script> + +<template> + <div class="diff-file-container"> + <div + v-if="diffMode === $options.diffModes.replaced" + class="diff-viewer"> + <div class="image js-replaced-image"> + <two-up-viewer + v-if="mode === $options.imageViewMode.twoup" + v-bind="$props"/> + <swipe-viewer + v-else-if="mode === $options.imageViewMode.swipe" + v-bind="$props"/> + <onion-skin-viewer + v-else-if="mode === $options.imageViewMode.onion" + v-bind="$props"/> + </div> + <div class="view-modes"> + <ul class="view-modes-menu"> + <li + :class="{ + active: mode === $options.imageViewMode.twoup + }" + @click="changeMode($options.imageViewMode.twoup)"> + {{ s__('ImageDiffViewer|2-up') }} + </li> + <li + :class="{ + active: mode === $options.imageViewMode.swipe + }" + @click="changeMode($options.imageViewMode.swipe)"> + {{ s__('ImageDiffViewer|Swipe') }} + </li> + <li + :class="{ + active: mode === $options.imageViewMode.onion + }" + @click="changeMode($options.imageViewMode.onion)"> + {{ s__('ImageDiffViewer|Onion skin') }} + </li> + </ul> + </div> + <div class="note-container"></div> + </div> + <div + v-else-if="diffMode === $options.diffModes.new" + class="diff-viewer added"> + <image-viewer + :path="newPath" + :project-path="projectPath" + /> + </div> + <div + v-else + class="diff-viewer deleted"> + <image-viewer + :path="oldPath" + :project-path="projectPath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index c159333d89a..3cba0c5e633 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -28,11 +28,11 @@ export default { <template> <button + :disabled="isDisabled || isLoading" class="dropdown-menu-toggle dropdown-menu-full-width" type="button" data-toggle="dropdown" aria-expanded="false" - :disabled="isDisabled || isLoading" > <loading-icon v-show="isLoading" @@ -42,8 +42,8 @@ export default { {{ toggleText }} </span> <span - class="dropdown-toggle-icon" v-show="!isLoading" + class="dropdown-toggle-icon" > <i class="fa fa-chevron-down" diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue index 1fe27eb97ab..b7a4613bdd2 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue @@ -15,8 +15,8 @@ export default { <template> <input - type="hidden" :name="name" :value="value" + type="hidden" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue index c2145a26e64..7f1912f6405 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -23,10 +23,10 @@ export default { <template> <div class="dropdown-input"> <input - class="dropdown-input-field" - type="search" v-model="searchQuery" :placeholder="placeholderText" + class="dropdown-input-field" + type="search" autocomplete="off" /> <i diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue index 9295be3e2b2..e6e92594b65 100644 --- a/app/assets/javascripts/vue_shared/components/expand_button.vue +++ b/app/assets/javascripts/vue_shared/components/expand_button.vue @@ -1,5 +1,7 @@ <script> import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + /** * Port of detail_behavior expand button. * @@ -12,6 +14,9 @@ import { __ } from '~/locale'; */ export default { name: 'ExpandButton', + components: { + Icon, + }, data() { return { isCollapsed: true, @@ -22,6 +27,9 @@ export default { return __('Click to expand text'); }, }, + destroyed() { + this.isCollapsed = true; + }, methods: { onClick() { this.isCollapsed = !this.isCollapsed; @@ -32,12 +40,15 @@ export default { <template> <span> <button - type="button" v-show="isCollapsed" - class="text-expander btn-blank" :aria-label="ariaLabel" + type="button" + class="text-expander btn-blank" @click="onClick"> - ... + <icon + :size="12" + name="ellipsis_h" + /> </button> <span v-if="!isCollapsed"> <slot name="expanded"></slot> diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index be2755452e2..878c805ada5 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -73,8 +73,8 @@ export default { <template> <span> <svg - :class="[iconSizeClass, cssClasses]" v-if="!loading && !folder" + :class="[iconSizeClass, cssClasses]" > <use v-bind="{ 'xlink:href':spriteHref }" /> </svg> diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index 9ffbaae3ea5..9bca1993ccc 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -513,7 +513,7 @@ const fileNameIcons = { 'credits.md': 'credits', 'credits.md.rendered': 'credits', '.flowconfig': 'flow', - 'favicon.ico': 'favicon', + 'favicon.png': 'favicon', 'karma.conf.js': 'karma', 'karma.conf.ts': 'karma', 'karma.conf.coffee': 'karma', diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue index d5d5a7d3798..b298b989203 100644 --- a/app/assets/javascripts/vue_shared/components/gl_modal.vue +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -1,15 +1,21 @@ <script> const buttonVariants = ['danger', 'primary', 'success', 'warning']; +const sizeVariants = ['sm', 'md', 'lg', 'xl']; export default { name: 'GlModal', - props: { id: { type: String, required: false, default: null, }, + modalSize: { + type: String, + required: false, + default: 'md', + validator: value => sizeVariants.includes(value), + }, headerTitleText: { type: String, required: false, @@ -27,7 +33,11 @@ export default { default: '', }, }, - + computed: { + modalSizeClass() { + return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`; + }, + }, methods: { emitCancel(event) { this.$emit('cancel', event); @@ -47,6 +57,7 @@ export default { role="dialog" > <div + :class="modalSizeClass" class="modal-dialog" role="document" > @@ -59,10 +70,10 @@ export default { </slot> </h4> <button + :aria-label="s__('Modal|Close')" type="button" class="close js-modal-close-action" data-dismiss="modal" - :aria-label="s__('Modal|Close')" @click="emitCancel($event)" > <span aria-hidden="true">×</span> @@ -85,9 +96,9 @@ export default { {{ s__('Modal|Cancel') }} </button> <button + :class="`btn-${footerPrimaryButtonVariant}`" type="button" class="btn js-modal-primary-action" - :class="`btn-${footerPrimaryButtonVariant}`" data-dismiss="modal" @click="emitSubmit($event)" > diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index ca17fa06a00..62d35f6547d 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -117,8 +117,8 @@ export default { </section> <section - class="header-action-buttons" v-if="actions.length" + class="header-action-buttons" > <template v-for="(action, i) in actions" @@ -135,21 +135,21 @@ export default { <a v-else-if="action.type === 'ujs-link'" :href="action.path" - data-method="post" - rel="nofollow" :class="action.cssClass" :key="i" + data-method="post" + rel="nofollow" > {{ action.label }} </a> <button v-else-if="action.type === 'button'" - @click="onClickAction(action)" :disabled="action.isLoading" :class="action.cssClass" - type="button" :key="i" + type="button" + @click="onClickAction(action)" > {{ action.label }} <i @@ -162,11 +162,11 @@ export default { </template> <button v-if="hasSidebarButton" + id="toggleSidebar" type="button" class="btn btn-default d-block d-sm-none d-md-none sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header" aria-label="Toggle Sidebar" - id="toggleSidebar" > <i class="fa fa-angle-double-left" diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue index 23010f40f26..4ffc811e714 100644 --- a/app/assets/javascripts/vue_shared/components/identicon.vue +++ b/app/assets/javascripts/vue_shared/components/identicon.vue @@ -43,9 +43,9 @@ export default { <template> <div - class="avatar identicon" :class="sizeClass" - :style="identiconStyles"> + :style="identiconStyles" + class="avatar identicon"> {{ identiconTitle }} </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index 3d39b3ab173..ca8ce554588 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -33,11 +33,11 @@ <template> <div class="issuable-note-warning"> <icon + v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" aria-hidden="true" - v-if="!isLockedAndConfidential" /> <span v-if="isLockedAndConfidential"> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js new file mode 100644 index 00000000000..02f28da8bb0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js @@ -0,0 +1,5 @@ +export function pixeliseValue(val) { + return val ? `${val}px` : ''; +} + +export default {}; diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 88c13a1f340..2ff0c056b9c 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -54,19 +54,19 @@ <template> <button - @click="onClick" - type="button" :class="containerClass" :disabled="loading || disabled" + type="button" + @click="onClick" > <transition name="fade"> <loading-icon v-if="loading" :inline="true" - class="js-loading-button-icon" :class="{ 'append-right-5': label }" + class="js-loading-button-icon" /> </transition> <transition name="fade"> diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue index 12a75e016d7..db22c5f02cd 100644 --- a/app/assets/javascripts/vue_shared/components/loading_icon.vue +++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue @@ -35,10 +35,10 @@ :is="rootElementType" class="loading-container text-center"> <i - class="fa fa-spin fa-spinner" :class="cssClass" - aria-hidden="true" :aria-label="label" + class="fa fa-spin fa-spinner" + aria-hidden="true" > </i> </component> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 12c7d125062..298971a36b2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -62,7 +62,15 @@ /* GLForm class handles all the toolbar buttons */ - return new GLForm($(this.$refs['gl-form']), this.enableAutocomplete); + return new GLForm($(this.$refs['gl-form']), { + emojis: this.enableAutocomplete, + members: this.enableAutocomplete, + issues: this.enableAutocomplete, + mergeRequests: this.enableAutocomplete, + epics: this.enableAutocomplete, + milestones: this.enableAutocomplete, + labels: this.enableAutocomplete, + }); }, beforeDestroy() { const glForm = $(this.$refs['gl-form']).data('glForm'); @@ -117,17 +125,17 @@ <template> <div - class="md-area js-vue-markdown-field" + ref="gl-form" :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }" - ref="gl-form"> + class="md-area js-vue-markdown-field"> <markdown-header :preview-markdown="previewMarkdown" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" /> <div - class="md-write-holder" v-show="!previewMarkdown" + class="md-write-holder" > <div class="zen-backdrop"> <slot name="textarea"></slot> @@ -137,8 +145,8 @@ aria-label="Enter zen mode" > <icon - name="screen-normal" :size="32" + name="screen-normal" /> </a> <markdown-toolbar @@ -149,8 +157,8 @@ </div> </div> <div - class="md md-preview-holder md-preview" v-show="previewMarkdown" + class="md md-preview-holder md-preview js-vue-md-preview" > <div ref="markdown-preview" @@ -164,8 +172,8 @@ <template v-if="previewMarkdown && !markdownPreviewLoading"> <div v-if="referencedCommands" - v-html="referencedCommands" class="referenced-commands" + v-html="referencedCommands" > </div> <div diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index db453c30576..83171ae50b8 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -54,8 +54,8 @@ <div class="md-header"> <ul class="nav-links clearfix"> <li - class="md-header-tab" :class="{ active: !previewMarkdown }" + class="md-header-tab" > <a class="js-write-link" @@ -67,11 +67,11 @@ </a> </li> <li - class="md-header-tab" :class="{ active: previewMarkdown }" + class="md-header-tab" > <a - class="js-preview-link" + class="js-preview-link js-md-preview-button" href="#md-preview-holder" tabindex="-1" @click.prevent="previewMarkdownTab($event)" @@ -80,8 +80,8 @@ </a> </li> <li - class="md-header-toolbar" :class="{ active: !previewMarkdown }" + class="md-header-toolbar" > <toolbar-button tag="**" @@ -94,8 +94,8 @@ icon="italic" /> <toolbar-button - tag="> " :prepend="true" + tag="> " button-title="Insert a quote" icon="quote" /> @@ -106,20 +106,20 @@ icon="code" /> <toolbar-button - tag="* " :prepend="true" + tag="* " button-title="Add a bullet list" icon="list-bulleted" /> <toolbar-button - tag="1. " :prepend="true" + tag="1. " button-title="Add a numbered list" icon="list-numbered" /> <toolbar-button - tag="* [ ] " :prepend="true" + tag="* [ ] " button-title="Add a task list" icon="task-done" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 2d2d69ebeb2..9f1e009efdd 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -39,15 +39,15 @@ <template> <button v-tooltip - type="button" - class="toolbar-btn js-md" - tabindex="-1" - data-container="body" :data-md-tag="tag" :data-md-block="tagBlock" :data-md-prepend="prepend" :title="buttonTitle" :aria-label="buttonTitle" + type="button" + class="toolbar-btn js-md" + tabindex="-1" + data-container="body" > <icon :name="icon" diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.vue b/app/assets/javascripts/vue_shared/components/memory_graph.vue index b07f6b07afe..522091ea889 100644 --- a/app/assets/javascripts/vue_shared/components/memory_graph.vue +++ b/app/assets/javascripts/vue_shared/components/memory_graph.vue @@ -113,19 +113,19 @@ export default { <template> <div class="memory-graph-container"> <svg - class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" + class="has-tooltip" xmlns="http://www.w3.org/2000/svg"> <path :d="pathD" :viewBox="pathViewBox" /> <circle - r="1.5" :cx="dotX" :cy="dotY" + r="1.5" tranform="translate(0 -1)" /> </svg> diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index 08d4936f480..99d61b5639d 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -59,9 +59,9 @@ export default { }" > <a + :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)" - :class="`js-${scope}-tab-${tab.scope}`" > {{ tab.name }} diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index eccba61a8c0..38115f268bb 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -54,7 +54,7 @@ <div class="note-header"> <div class="note-header-info"> <a :href="getUserData.path"> - <span class="d-none d-sm-block">{{ getUserData.name }}</span> + <span class="d-none d-sm-inline-block">{{ getUserData.name }}</span> <span class="note-headline-light">@{{ getUserData.username }}</span> </a> </div> diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue index 80e3db52cb0..2eb6c20b2c0 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -14,11 +14,12 @@ </template> <script> - import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; - export default { - components: { - skeletonLoadingContainer, - }, - }; +export default { + name: 'SkeletonNote', + components: { + skeletonLoadingContainer, + }, +}; </script> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index aac10f84087..2122d0a508e 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -1,51 +1,75 @@ <script> - /** - * Common component to render a system note, icon and user information. - * - * This component needs to be used with a vuex store. - * That vuex store needs to have a `targetNoteHash` getter - * - * @example - * <system-note - * :note="{ - * id: String, - * author: Object, - * createdAt: String, - * note_html: String, - * system_note_icon_name: String - * }" - * /> - */ - import { mapGetters } from 'vuex'; - import noteHeader from '~/notes/components/note_header.vue'; - import { spriteIcon } from '../../../lib/utils/common_utils'; +/** + * Common component to render a system note, icon and user information. + * + * This component needs to be used with a vuex store. + * That vuex store needs to have a `targetNoteHash` getter + * + * @example + * <system-note + * :note="{ + * id: String, + * author: Object, + * createdAt: String, + * note_html: String, + * system_note_icon_name: String + * }" + * /> + */ +import $ from 'jquery'; +import { mapGetters } from 'vuex'; +import noteHeader from '~/notes/components/note_header.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { spriteIcon } from '../../../lib/utils/common_utils'; - export default { - name: 'SystemNote', - components: { - noteHeader, +const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + +export default { + name: 'SystemNote', + components: { + Icon, + noteHeader, + }, + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + expanded: false, + }; + }, + computed: { + ...mapGetters(['targetNoteHash']), + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; }, - props: { - note: { - type: Object, - required: true, - }, + iconHtml() { + return spriteIcon(this.note.system_note_icon_name); }, - computed: { - ...mapGetters([ - 'targetNoteHash', - ]), - noteAnchorId() { - return `note_${this.note.id}`; - }, - isTargetNote() { - return this.targetNoteHash === this.noteAnchorId; - }, - iconHtml() { - return spriteIcon(this.note.system_note_icon_name); - }, + toggleIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; }, - }; + // following 2 methods taken from code in `collapseLongCommitList` of notes.js: + actionTextHtml() { + return $(this.note.note_html) + .unwrap() + .html(); + }, + hasMoreCommits() { + return ( + $(this.note.note_html) + .filter('ul') + .children().length > MAX_VISIBLE_COMMIT_LIST_COUNT + ); + }, + }, +}; </script> <template> @@ -64,8 +88,35 @@ :author="note.author" :created-at="note.created_at" :note-id="note.id" - :action-text-html="note.note_html" - /> + > + <span v-html="actionTextHtml"></span> + </note-header> + </div> + <div class="note-body"> + <div + :class="{ + 'system-note-commit-list': hasMoreCommits, + 'hide-shade': expanded + }" + class="note-text" + v-html="note.note_html" + ></div> + <div + v-if="hasMoreCommits" + class="flex-list" + > + <div + class="system-note-commit-list-toggler flex-row" + @click="expanded = !expanded" + > + <Icon + :name="toggleIcon" + :size="8" + class="append-right-5" + /> + <span>Toggle commit list</span> + </div> + </div> </div> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/panel_resizer.vue b/app/assets/javascripts/vue_shared/components/panel_resizer.vue index abbe9a22717..8c2dcc2d902 100644 --- a/app/assets/javascripts/vue_shared/components/panel_resizer.vue +++ b/app/assets/javascripts/vue_shared/components/panel_resizer.vue @@ -82,9 +82,9 @@ <template> <div - class="dragHandle" :class="className" :style="cursorStyle" + class="dragHandle" @mousedown="startDrag" @dblclick="resetSize" ></div> diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue index 279cc1de5bb..97ca4d93bd7 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue +++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue @@ -85,7 +85,6 @@ <template> <img v-tooltip - class="avatar" :class="{ lazy: lazy, [avatarSizeClass]: true, @@ -99,5 +98,6 @@ :data-container="tooltipContainer" :data-placement="tooltipPlacement" :title="tooltipText" + class="avatar" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue index 21ffdc1dc86..a2a9a5e6987 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue +++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue @@ -66,10 +66,10 @@ <template> <deprecated-modal - kind="warning" - class="recaptcha-modal js-recaptcha-modal" :hide-footer="true" :title="__('Please solve the reCAPTCHA')" + kind="warning" + class="recaptcha-modal js-recaptcha-modal" @cancel="close" > <div slot="body"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 71ec34f2c7a..74998a4787d 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -97,8 +97,8 @@ <template> <div - class="block" :class="blockClass" + class="block" > <div class="issuable-sidebar-header"> <toggle-sidebar @@ -107,8 +107,8 @@ /> </div> <collapsed-calendar-icon - class="sidebar-collapsed-icon" :text="collapsedText" + class="sidebar-collapsed-icon" /> <div class="title"> {{ label }} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index f155ac2be02..a3fc358130f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -143,8 +143,8 @@ export default { :value="label.id" /> <div - class="dropdown" ref="dropdown" + class="dropdown" > <dropdown-button :ability-name="abilityName" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 47497c1de98..48d2f16f554 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -53,10 +53,7 @@ export default { <template> <button - type="button" ref="dropdownButton" - class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" - data-toggle="dropdown" :class="{ 'js-extra-options': showExtraOptions }" :data-ability-name="abilityName" :data-field-name="fieldName" @@ -64,6 +61,9 @@ export default { :data-labels="labelsPath" :data-namespace-path="namespace" :data-show-any="showExtraOptions" + type="button" + class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" + data-toggle="dropdown" > <span class="dropdown-toggle-text"> {{ dropdownToggleText }} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue index 3c400afdc1d..fe895136ccc 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue @@ -19,9 +19,9 @@ export default { <div class="dropdown-page-two dropdown-new-label"> <div class="dropdown-title"> <button + :aria-label="__('Go back')" type="button" class="dropdown-title-button dropdown-menu-back" - :aria-label="__('Go back')" > <i aria-hidden="true" @@ -32,9 +32,9 @@ export default { </button> {{ headerTitle }} <button + :aria-label="__('Close')" type="button" class="dropdown-title-button dropdown-menu-close" - :aria-label="__('Close')" > <i aria-hidden="true" @@ -48,19 +48,19 @@ export default { <div class="dropdown-labels-error js-label-error"></div> <input id="new_label_name" + :placeholder="__('Name new label')" type="text" class="default-dropdown-input" - :placeholder="__('Name new label')" /> <div class="suggest-colors suggest-colors-dropdown"> <a v-for="(color, index) in suggestedColors" - href="#" :key="index" :data-color="color" :style="{ backgroundColor: color, }" + href="#" > </a> @@ -69,9 +69,9 @@ export default { <div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div> <input id="new_label_color" + :placeholder="__('Assign custom color like #FF0000')" type="text" class="default-dropdown-input" - :placeholder="__('Assign custom color like #FF0000')" /> </div> <div class="clearfix"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue index 5f61e9fbe80..d64ad016f9b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue @@ -34,9 +34,9 @@ export default { </li> <li> <a + :href="labelsWebUrl" data-is-link="true" class="dropdown-external-link" - :href="labelsWebUrl" > {{ manageLabelsTitle }} </a> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue index 7664acdf19c..e98b6392827 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue @@ -6,9 +6,9 @@ export default {}; <div class="dropdown-title"> <span>{{ __('Assign labels') }}</span> <button + :aria-label="__('Close')" type="button" class="dropdown-title-button dropdown-menu-close" - :aria-label="__('Close')" > <i aria-hidden="true" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue index ae633460c95..80d65a2a534 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue @@ -5,10 +5,10 @@ export default {}; <template> <div class="dropdown-input"> <input + :placeholder="__('Search')" autocomplete="off" class="dropdown-input-field" type="search" - :placeholder="__('Search')" /> <i aria-hidden="true" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue index 69d588eb25d..10e990f8a80 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue @@ -35,7 +35,12 @@ export default { </script> <template> - <div class="hide-collapsed value issuable-show-labels js-value"> + <div + :class="{ + 'has-labels':!isEmpty, + }" + class="hide-collapsed value issuable-show-labels js-value" + > <span v-if="isEmpty" class="text-secondary" @@ -43,18 +48,18 @@ export default { <slot>{{ __('None') }}</slot> </span> <a - v-else v-for="label in labels" + v-else :key="label.id" :href="labelFilterUrl(label)" > <span v-tooltip - class="label color-label" - data-placement="bottom" - data-container="body" :style="labelStyle(label)" :title="label.description" + class="badge color-label" + data-placement="bottom" + data-container="body" > {{ label.title }} </span> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue index 68fa2ab8d01..af297f3c408 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -37,10 +37,10 @@ export default { <template> <div v-tooltip + :title="labelsList" class="sidebar-collapsed-icon" data-placement="left" data-container="body" - :title="labelsList" @click="handleClick" > <i diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index de6f8c32e74..ac2e99abe77 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -28,21 +28,21 @@ export default { <template> <button + v-tooltip + :title="tooltipLabel" type="button" class="btn btn-blank gutter-toggle btn-sidebar-action" - @click="toggle" - v-tooltip data-container="body" data-placement="left" - :title="tooltipLabel" + @click="toggle" > <i - aria-label="toggle collapse" - class="fa" :class="{ 'fa-angle-double-right': !collapsed, 'fa-angle-double-left': collapsed }" + aria-label="toggle collapse" + class="fa" > </i> </button> diff --git a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue index 16304e4815d..4a5ffbe5d5a 100644 --- a/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue +++ b/app/assets/javascripts/vue_shared/components/skeleton_loading_container.vue @@ -22,10 +22,10 @@ <template> <div - class="animation-container" :class="{ 'animation-container-small': small, }" + class="animation-container" > <div v-for="(css, index) in lineClasses" diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue index 86f06c8d266..b1c2df54ef6 100644 --- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue +++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue @@ -84,8 +84,8 @@ export default { <template> <div - class="stacked-progress-bar" :class="cssClass" + class="stacked-progress-bar" > <span v-if="!totalCount" @@ -96,30 +96,30 @@ export default { <span v-tooltip v-if="successPercent" - class="status-green" - data-placement="bottom" :title="successTooltip" :style="successBarStyle" + class="status-green" + data-placement="bottom" > {{ successPercent }}% </span> <span v-tooltip v-if="neutralPercent" - class="status-neutral" - data-placement="bottom" :title="neutralTooltip" :style="neutralBarStyle" + class="status-neutral" + data-placement="bottom" > {{ neutralPercent }}% </span> <span v-tooltip v-if="failurePercent" - class="status-red" - data-placement="bottom" :title="failureTooltip" :style="failureBarStyle" + class="status-red" + data-placement="bottom" > {{ failurePercent }}% </span> diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue index 22fc5757447..8e9621c956f 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -55,7 +55,7 @@ }, getItems() { const total = this.pageInfo.totalPages; - const page = this.pageInfo.page; + const { page } = this.pageInfo; const items = []; if (page > 1) { @@ -124,15 +124,18 @@ break; } }, + hideOnSmallScreen(item) { + return !item.first && !item.last && !item.next && !item.prev && !item.active; + }, }, }; </script> <template> <div v-if="showPagination" - class="gl-pagination" + class="gl-pagination prepend-top-default" > - <ul class="pagination clearfix"> + <ul class="pagination justify-content-center"> <li v-for="(item, index) in getItems" :key="index" @@ -142,12 +145,17 @@ 'js-next-button': item.next, 'js-last-button': item.last, 'js-first-button': item.first, + 'd-none d-md-block': hideOnSmallScreen(item), separator: item.separator, active: item.active, - disabled: item.disabled + disabled: item.disabled || item.separator }" + class="page-item" > - <a @click.prevent="changePage(item.title, item.disabled)"> + <a + class="page-link" + @click.prevent="changePage(item.title, item.disabled)" + > {{ item.title }} </a> </li> diff --git a/app/assets/javascripts/vue_shared/components/tabs/tab.vue b/app/assets/javascripts/vue_shared/components/tabs/tab.vue index 2a35d6bc151..1c6011dcfd0 100644 --- a/app/assets/javascripts/vue_shared/components/tabs/tab.vue +++ b/app/assets/javascripts/vue_shared/components/tabs/tab.vue @@ -26,15 +26,20 @@ export default { created() { this.isTab = true; }, + updated() { + if (this.$parent) { + this.$parent.$forceUpdate(); + } + }, }; </script> <template> <div - class="tab-pane" :class="{ active: localActive }" + class="tab-pane" role="tabpanel" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/components/tabs/tabs.js b/app/assets/javascripts/vue_shared/components/tabs/tabs.js index 4362264caa5..9b9e4bb47bd 100644 --- a/app/assets/javascripts/vue_shared/components/tabs/tabs.js +++ b/app/assets/javascripts/vue_shared/components/tabs/tabs.js @@ -1,4 +1,11 @@ export default { + props: { + stopPropagation: { + type: Boolean, + required: false, + default: false, + }, + }, data() { return { currentIndex: 0, @@ -13,7 +20,12 @@ export default { this.tabs = this.$children.filter(child => child.isTab); this.currentIndex = this.tabs.findIndex(tab => tab.localActive); }, - setTab(index) { + setTab(e, index) { + if (this.stopPropagation) { + e.stopPropagation(); + e.preventDefault(); + } + this.tabs[this.currentIndex].localActive = false; this.tabs[index].localActive = true; @@ -36,7 +48,7 @@ export default { href: '#', }, on: { - click: () => this.setTab(i), + click: e => this.setTab(e, i), }, }, tab.$slots.title || tab.title, diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index 09031d3ffa1..a897300b62b 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -63,26 +63,26 @@ <label class="toggle-wrapper"> <input v-if="name" - type="hidden" :name="name" :value="value" + type="hidden" /> <button - type="button" - class="project-feature-toggle" :aria-label="ariaLabel" :class="{ 'is-checked': value, 'is-disabled': disabledInput, 'is-loading': isLoading }" + type="button" + class="project-feature-toggle" @click="toggleFeature" > <loadingIcon class="loading-icon" /> <span class="toggle-icon"> <icon - css-classes="toggle-icon-svg" - :name="toggleIcon"/> + :name="toggleIcon" + css-classes="toggle-icon-svg"/> </span> </button> </label> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index cc9cc46bb4c..3a413c74410 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -85,7 +85,6 @@ export default { <template> <img v-tooltip - class="avatar" :class="{ lazy: lazy, [avatarSizeClass]: true, @@ -99,5 +98,7 @@ export default { :data-container="tooltipContainer" :data-placement="tooltipPlacement" :title="tooltipText" + class="avatar" + data-boundary="window" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 6955d164def..01c36fec41a 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -84,8 +84,8 @@ export default { <template> <a - class="user-avatar-link" - :href="linkHref"> + :href="linkHref" + class="user-avatar-link"> <user-avatar-image :img-src="imgSrc" :img-alt="imgAlt" @@ -94,8 +94,8 @@ export default { :tooltip-text="avatarTooltipText" :tooltip-placement="tooltipPlacement" /><span - v-if="shouldShowUsername" v-tooltip + v-if="shouldShowUsername" :title="tooltipText" :tooltip-placement="tooltipPlacement" >{{ username }}</span> diff --git a/app/assets/javascripts/vue_shared/models/assignee.js b/app/assets/javascripts/vue_shared/models/assignee.js new file mode 100644 index 00000000000..4a29b0d0581 --- /dev/null +++ b/app/assets/javascripts/vue_shared/models/assignee.js @@ -0,0 +1,13 @@ +export default class ListAssignee { + constructor(obj, defaultAvatar) { + this.id = obj.id; + this.name = obj.name; + this.username = obj.username; + this.avatar = obj.avatar_url || obj.avatar || defaultAvatar; + this.path = obj.path; + this.state = obj.state; + this.webUrl = obj.web_url || obj.webUrl; + } +} + +window.ListAssignee = ListAssignee; diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js index b9693892f45..73b9131e5ba 100644 --- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js @@ -28,7 +28,7 @@ Vue.http.interceptors.push((request, next) => { response.headers.forEach((value, key) => { headers[key] = value; }); - // eslint-disable-next-line no-param-reassign + response.headers = headers; }); }); diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index f68a4f28714..0138c9be803 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len, class-methods-use-this */ +/* eslint-disable func-names, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len, class-methods-use-this */ // Zen Mode (full screen) textarea // diff --git a/app/assets/stylesheets/bootstrap.scss b/app/assets/stylesheets/bootstrap.scss new file mode 100644 index 00000000000..a040c2f8c20 --- /dev/null +++ b/app/assets/stylesheets/bootstrap.scss @@ -0,0 +1,37 @@ +/* + * Includes specific styles from the bootstrap4 foler in node_modules + */ + +@import "../../../node_modules/bootstrap/scss/functions"; +@import "../../../node_modules/bootstrap/scss/variables"; +@import "../../../node_modules/bootstrap/scss/mixins"; +@import "../../../node_modules/bootstrap/scss/root"; +@import "../../../node_modules/bootstrap/scss/reboot"; +@import "../../../node_modules/bootstrap/scss/type"; +@import "../../../node_modules/bootstrap/scss/images"; +@import "../../../node_modules/bootstrap/scss/code"; +@import "../../../node_modules/bootstrap/scss/grid"; +@import "../../../node_modules/bootstrap/scss/tables"; +@import "../../../node_modules/bootstrap/scss/forms"; +@import "../../../node_modules/bootstrap/scss/buttons"; +@import "../../../node_modules/bootstrap/scss/transitions"; +@import "../../../node_modules/bootstrap/scss/dropdown"; +@import "../../../node_modules/bootstrap/scss/button-group"; +@import "../../../node_modules/bootstrap/scss/input-group"; +@import "../../../node_modules/bootstrap/scss/custom-forms"; +@import "../../../node_modules/bootstrap/scss/nav"; +@import "../../../node_modules/bootstrap/scss/navbar"; +@import "../../../node_modules/bootstrap/scss/card"; +@import "../../../node_modules/bootstrap/scss/breadcrumb"; +@import "../../../node_modules/bootstrap/scss/pagination"; +@import "../../../node_modules/bootstrap/scss/badge"; +@import "../../../node_modules/bootstrap/scss/alert"; +@import "../../../node_modules/bootstrap/scss/progress"; +@import "../../../node_modules/bootstrap/scss/media"; +@import "../../../node_modules/bootstrap/scss/list-group"; +@import "../../../node_modules/bootstrap/scss/close"; +@import "../../../node_modules/bootstrap/scss/modal"; +@import "../../../node_modules/bootstrap/scss/tooltip"; +@import "../../../node_modules/bootstrap/scss/popover"; +@import "../../../node_modules/bootstrap/scss/utilities"; +@import "../../../node_modules/bootstrap/scss/print"; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index d8e57834f9e..ded33e8b151 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -24,16 +24,60 @@ html { font-size: 14px; } +legend { + border-bottom: 1px solid $border-color; + margin-bottom: 20px; +} + button, html [type="button"], [type="reset"], -[type="submit"] { +[type="submit"], +[role="button"] { // Override bootstrap reboot -webkit-appearance: inherit; + cursor: pointer; } -[role="button"] { - cursor: pointer; +h1, +h2, +h3, +h4, +h5, +h6 { + color: $gl-text-color; + font-weight: 600; +} + +h1, +.h1, +h2, +.h2, +h3, +.h3 { + margin-top: 20px; + margin-bottom: 10px; +} + +h4, +.h4, +h5, +.h5, +h6, +.h6 { + margin-top: 10px; + margin-bottom: 10px; +} + +h5, +.h5 { + font-size: $gl-font-size; +} + +input[type="file"] { + // Bootstrap 4 file input height is taller by default + // which makes them look ugly + line-height: 1; } b, @@ -45,7 +89,12 @@ a { color: $gl-link-color; } +hr { + overflow: hidden; +} + .form-group.row .col-form-label { + padding-top: 0; // Bootstrap 4 aligns labels to the left // for horizontal forms @include media-breakpoint-up(md) { @@ -53,10 +102,25 @@ a { } } +kbd { + display: inline-block; +} + code { padding: 2px 4px; + color: $red-600; background-color: $red-100; border-radius: 3px; + + .code > & { + background-color: inherit; + padding: unset; + } + + .build-trace & { + background-color: inherit; + padding: inherit; + } } table { @@ -87,6 +151,16 @@ table { color: $gl-text-color-secondary !important; } +.bg-success, +.bg-primary, +.bg-info, +.bg-danger, +.bg-warning { + .card-header { + color: $white-light; + } +} + // Polyfill deprecated selectors .hidden { @@ -104,7 +178,16 @@ table { display: none; } -.badge { +h3.popover-header { + // Default bootstrap popovers use <h3> + // which we default to having a top margin + margin-top: 0; +} + +// Add to .label so that old system notes that are saved to the db +// will still receive the correct styling +.badge, +.label { padding: 4px 5px; font-size: 12px; font-style: normal; @@ -139,6 +222,19 @@ table { &:not(:last-of-type) { border-bottom: 1px solid $well-inner-border; } + + p, + ol, + ul, + .form-group { + &:last-of-type { + margin-bottom: 0; + } + } + } + + .badge.badge-gray { + background-color: $well-expand-item; } } @@ -160,9 +256,21 @@ table { } } +.card-header { + h3.card-title, + h4.card-title { + margin-top: 0; + } +} + .nav-tabs { + // Override bootstrap's default border + border-bottom: 0; + .nav-link { - border: 0; + border-top: 0; + border-left: 0; + border-right: 0; } .nav-item { @@ -173,3 +281,60 @@ table { pre code { white-space: pre-wrap; } + +.alert, +.flash-notice { + border-radius: 0; +} + +.alert-success { + background-color: $green-500; + border-color: $green-500; +} + +.alert-info { + background-color: $blue-500; + border-color: $blue-500; +} + +.alert-warning { + background-color: $orange-500; + border-color: $orange-500; +} + +.alert-danger { + background-color: $red-500; + border-color: $red-500; +} + +.alert-success, +.alert-info, +.alert-warning, +.alert-danger, +.flash-notice { + color: $white-light; + + h4, + a:not(.btn), + .alert-link { + color: $white-light; + } +} + +input[type=color].form-control { + height: $input-height; +} + +.toggle-sidebar-button { + .collapse-text, + .icon-angle-double-left, + .icon-angle-double-right { + color: $gl-text-color-secondary; + } +} + +.project-templates-buttons { + .btn { + vertical-align: unset; + } +} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 7c28024001f..c46b0b5db09 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -1,6 +1,7 @@ @import 'framework/variables'; @import 'framework/mixins'; -@import '../../../node_modules/bootstrap/scss/bootstrap'; + +@import 'bootstrap'; @import 'bootstrap_migration'; @import 'framework/layout'; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 14cd32da9eb..549a8730301 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -251,3 +251,12 @@ $skeleton-line-widths: ( transform: translateX(468px); } } + +.slide-down-enter-active { + transition: transform 0.2s; +} + +.slide-down-enter, +.slide-down-leave-to { + transform: translateY(-30%); +} diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index a538b5a2946..8d11b92cf88 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -104,6 +104,10 @@ position: relative; top: 3px; } + + > gl-emoji { + line-height: 1.5; + } } .award-menu-holder { diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index c5be27f2d29..340fddd398b 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -13,9 +13,11 @@ &.diff-collapsed { padding: 5px; + line-height: 34px; .click-to-expand { cursor: pointer; + vertical-align: initial; } } } @@ -348,11 +350,6 @@ } } -.flex-container-block { - display: -webkit-flex; - display: flex; -} - .flex-right { margin-left: auto; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 0115f542c88..523fcb05a87 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -121,10 +121,6 @@ &.btn-sm { margin-left: $btn-sm-side-margin; } - - &.btn-xs { - margin-left: $btn-xs-side-margin; - } } @mixin btn-svg { @@ -150,10 +146,6 @@ line-height: 18px; } - &.btn-xs { - padding: 2px 5px; - } - &.btn-success, &.btn-new, &.btn-create, @@ -497,6 +489,10 @@ fieldset[disabled] .btn, } } +[readonly] { + cursor: default; +} + .btn-no-padding { padding: 0; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 1e7b9534275..218e37602dd 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -262,12 +262,7 @@ li.note { } .milestone { - &.milestone-closed { - background: $gray-light; - } - .progress { - margin-bottom: 0; margin-top: 4px; box-shadow: none; background-color: $border-gray-light; @@ -305,14 +300,6 @@ img.emoji { margin-bottom: 10px; } -.btn-sign-in { - text-shadow: none; - - @include media-breakpoint-up(sm) { - margin-top: 8px; - } -} - .side-filters { fieldset { margin-bottom: 15px; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 1a415e1b852..ea4cb9a0b75 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -26,19 +26,25 @@ margin-right: 2px; width: $contextual-sidebar-width; - a { + > a, + > button { transition: padding $sidebar-transition-duration; font-weight: $gl-font-weight-bold; display: flex; + width: 100%; align-items: center; padding: 10px 16px 10px 10px; color: $gl-text-color; - } + background-color: transparent; + border: 0; + text-align: left; - &:hover, - a:hover { - background-color: $link-hover-background; - color: $gl-text-color; + &:hover, + &:focus { + background-color: $link-hover-background; + color: $gl-text-color; + outline: 0; + } } .avatar-container { @@ -62,8 +68,7 @@ } .nav-sidebar { - transition: width $sidebar-transition-duration, - left $sidebar-transition-duration; + transition: width $sidebar-transition-duration, left $sidebar-transition-duration; position: fixed; z-index: 400; width: $contextual-sidebar-width; @@ -71,12 +76,12 @@ bottom: 0; left: 0; background-color: $gray-light; - box-shadow: inset -2px 0 0 $border-color; + box-shadow: inset -1px 0 0 $border-color; transform: translate3d(0, 0, 0); &:not(.sidebar-collapsed-desktop) { @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) { - box-shadow: inset -2px 0 0 $border-color, + box-shadow: inset -1px 0 0 $border-color, 2px 1px 3px $dropdown-shadow-color; } } @@ -208,7 +213,7 @@ > li { > a { @include media-breakpoint-up(sm) { - margin-right: 2px; + margin-right: 1px; } &:hover { @@ -218,7 +223,7 @@ &.is-showing-fly-out { > a { - margin-right: 2px; + margin-right: 1px; } .sidebar-sub-level-items { @@ -311,14 +316,14 @@ .toggle-sidebar-button, .close-nav-button { - width: $contextual-sidebar-width - 2px; + width: $contextual-sidebar-width - 1px; transition: width $sidebar-transition-duration; position: fixed; bottom: 0; padding: $gl-padding; background-color: $gray-light; border: 0; - border-top: 2px solid $border-color; + border-top: 1px solid $border-color; color: $gl-text-color-secondary; display: flex; align-items: center; @@ -373,7 +378,7 @@ .toggle-sidebar-button { padding: 16px; - width: $contextual-sidebar-collapsed-width - 2px; + width: $contextual-sidebar-collapsed-width - 1px; .collapse-text, .icon-angle-double-left { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index b91d579cae6..74475daae14 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -35,6 +35,12 @@ @include media-breakpoint-down(xs) { width: 100%; } + + &.projects-dropdown-menu { + padding: 0; + overflow-y: initial; + max-height: initial; + } } .dropdown-toggle, diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 527e7d57c5c..3cde0490371 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -4,4 +4,5 @@ gl-emoji { vertical-align: middle; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 1.5em; + line-height: 0.9; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index f77ec4b6a2c..00eac1688f2 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -322,14 +322,17 @@ span.idiff { } .file-title-flex-parent { - display: flex; - align-items: center; - justify-content: space-between; - background-color: $gray-light; - border-bottom: 1px solid $border-color; - padding: 5px $gl-padding; - margin: 0; - border-radius: $border-radius-default $border-radius-default 0 0; + &, + .file-holder & { + display: flex; + align-items: center; + justify-content: space-between; + background-color: $gray-light; + border-bottom: 1px solid $border-color; + padding: 5px $gl-padding; + margin: 0; + border-radius: $border-radius-default $border-radius-default 0 0; + } .file-header-content { white-space: nowrap; @@ -337,6 +340,17 @@ span.idiff { text-overflow: ellipsis; padding-right: 30px; position: relative; + width: auto; + + @media (max-width: map-get($grid-breakpoints, sm)-1) { + width: 100%; + } + } + + .file-holder & { + .file-actions { + position: static; + } } .btn-clipboard { @@ -400,3 +414,51 @@ span.idiff { color: $common-gray-light; border: 1px solid $common-gray-light; } + +.preview-container { + height: 100%; + overflow: auto; + + .file-container { + background-color: $gray-darker; + display: flex; + height: 100%; + align-items: center; + justify-content: center; + + text-align: center; + + .file-content { + padding: $gl-padding; + max-width: 100%; + max-height: 100%; + + img { + max-width: 90%; + max-height: 70vh; + } + + .is-zoomable { + cursor: pointer; + cursor: zoom-in; + + &.is-zoomed { + cursor: pointer; + cursor: zoom-out; + max-width: none; + max-height: none; + margin-right: $gl-padding; + } + } + } + + .file-info { + font-size: $label-font-size; + color: $diff-image-info-color; + } + } + + .md-previewer { + padding: $gl-padding; + } +} diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 0ee5748952a..551a7e852ae 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -299,6 +299,7 @@ height: 14px; width: 14px; vertical-align: middle; + margin-bottom: 4px; } .dropdown-toggle-text { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 52c3f18a682..e4bcb92876d 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -10,6 +10,20 @@ @extend .alert; background-color: $blue-500; margin: 0; + + &.flash-notice-persistent { + background-color: $blue-100; + color: $gl-text-color; + + a { + color: $gl-link-color; + + &:hover { + color: $gl-link-hover-color; + text-decoration: none; + } + } + } } .flash-warning { @@ -28,7 +42,7 @@ display: inline-block; } - a.flash-action { + .flash-action { margin-left: 5px; text-decoration: none; font-weight: $gl-font-weight-normal; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index c76ea532912..282e424fc38 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -170,7 +170,7 @@ label { } .form-control::-webkit-input-placeholder { - color: $gl-text-color-secondary; + color: $placeholder-text-color; } .input-group { @@ -201,6 +201,10 @@ label { } .gl-show-field-errors { + .form-control { + height: 34px; + } + .gl-field-success-outline { border: 1px solid $green-600; @@ -239,3 +243,15 @@ label { } } } + +.input-icon-wrapper { + position: relative; + + .input-icon-right { + position: absolute; + right: 0.8em; + top: 50%; + transform: translateY(-50%); + color: $theme-gray-600; + } +} diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index e378e84ca1b..1cf12b1a015 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -19,6 +19,7 @@ .gfm-color_chip { display: inline-block; + line-height: 1; margin: 0 0 2px 4px; vertical-align: middle; border-radius: 3px; diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index 11e21edfc1b..aaa8bed3df0 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -3,26 +3,26 @@ */ @mixin gitlab-theme( - $color-100, - $color-200, - $color-500, - $color-700, - $color-800, - $color-900, + $location-badge-color, + $search-and-nav-links, + $active-tab-border, + $border-and-box-shadow, + $sidebar-text, + $nav-svg-color, $color-alternate ) { // Header .navbar-gitlab { - background-color: $color-900; + background-color: $nav-svg-color; .navbar-collapse { - color: $color-200; + color: $search-and-nav-links; } .container-fluid { .navbar-toggler { - border-left: 1px solid lighten($color-700, 10%); + border-left: 1px solid lighten($border-and-box-shadow, 10%); } } @@ -31,40 +31,40 @@ > li { > a:hover, > a:focus { - background-color: rgba($color-200, 0.2); + background-color: rgba($search-and-nav-links, 0.2); } &.active > a, - &.dropdown.open > a { - color: $color-900; + &.dropdown.show > a { + color: $nav-svg-color; background-color: $color-alternate; } &.line-separator { - border-left: 1px solid rgba($color-200, 0.2); + border-left: 1px solid rgba($search-and-nav-links, 0.2); } } } .navbar-sub-nav { - color: $color-200; + color: $search-and-nav-links; } .nav { > li { - color: $color-200; + color: $search-and-nav-links; > a { &.header-user-dropdown-toggle { .header-user-avatar { - border-color: $color-200; + border-color: $search-and-nav-links; } } &:hover, &:focus { @include media-breakpoint-up(sm) { - background-color: rgba($color-200, 0.2); + background-color: rgba($search-and-nav-links, 0.2); } svg { @@ -74,13 +74,13 @@ } &.active > a, - &.dropdown.open > a { - color: $color-900; + &.dropdown.show > a { + color: $nav-svg-color; background-color: $color-alternate; &:hover { svg { - fill: $color-900; + fill: $nav-svg-color; } } } @@ -88,7 +88,7 @@ .impersonated-user, .impersonated-user:hover { svg { - fill: $color-900; + fill: $nav-svg-color; } } } @@ -99,34 +99,34 @@ > a { &:hover, &:focus { - background-color: rgba($color-200, 0.2); + background-color: rgba($search-and-nav-links, 0.2); } } } .search { form { - background-color: rgba($color-200, 0.2); + background-color: rgba($search-and-nav-links, 0.2); &:hover { - background-color: rgba($color-200, 0.3); + background-color: rgba($search-and-nav-links, 0.3); } } .location-badge { - color: $color-100; - background-color: rgba($color-200, 0.1); - border-right: 1px solid $color-800; + color: $location-badge-color; + background-color: rgba($search-and-nav-links, 0.1); + border-right: 1px solid $sidebar-text; } .search-input::placeholder { - color: rgba($color-200, 0.8); + color: rgba($search-and-nav-links, 0.8); } .search-input-wrap { .search-icon, .clear-icon { - fill: rgba($color-200, 0.8); + fill: rgba($search-and-nav-links, 0.8); } } @@ -141,38 +141,34 @@ .search-input-wrap { .search-icon { - fill: rgba($color-200, 0.8); + fill: rgba($search-and-nav-links, 0.8); } } } } - .btn-sign-in { - background-color: $color-100; - color: $color-900; - } // Sidebar .nav-sidebar li.active { - box-shadow: inset 4px 0 0 $color-700; + box-shadow: inset 4px 0 0 $border-and-box-shadow; > a { - color: $color-800; + color: $sidebar-text; } svg { - fill: $color-800; + fill: $sidebar-text; } } .sidebar-top-level-items > li.active .badge.badge-pill { - color: $color-800; + color: $sidebar-text; } .nav-links li { &.active a, a.active { - border-bottom: 2px solid $color-500; + border-bottom: 2px solid $active-tab-border; .badge.badge-pill { font-weight: $gl-font-weight-bold; @@ -181,27 +177,23 @@ } .branch-header-title { - color: $color-700; - } - - .ide-file-list .file.file-active { - color: $color-700; + color: $border-and-box-shadow; } .ide-sidebar-link { &.active { - color: $color-700; - box-shadow: inset 3px 0 $color-700; + color: $border-and-box-shadow; + box-shadow: inset 3px 0 $border-and-box-shadow; &.is-right { - box-shadow: inset -3px 0 $color-700; + box-shadow: inset -3px 0 $border-and-box-shadow; } } } } body { - &.ui_indigo { + &.ui-indigo { @include gitlab-theme( $indigo-100, $indigo-200, @@ -213,19 +205,19 @@ body { ); } - &.ui_dark { + &.ui-light-indigo { @include gitlab-theme( - $theme-gray-100, - $theme-gray-200, - $theme-gray-500, - $theme-gray-700, - $theme-gray-800, - $theme-gray-900, + $indigo-100, + $indigo-200, + $indigo-500, + $indigo-500, + $indigo-700, + $indigo-700, $white-light ); } - &.ui_blue { + &.ui-blue { @include gitlab-theme( $theme-blue-100, $theme-blue-200, @@ -237,7 +229,19 @@ body { ); } - &.ui_green { + &.ui-light-blue { + @include gitlab-theme( + $theme-light-blue-100, + $theme-light-blue-200, + $theme-light-blue-500, + $theme-light-blue-500, + $theme-light-blue-700, + $theme-light-blue-700, + $white-light + ); + } + + &.ui-green { @include gitlab-theme( $theme-green-100, $theme-green-200, @@ -249,7 +253,55 @@ body { ); } - &.ui_light { + &.ui-light-green { + @include gitlab-theme( + $theme-green-100, + $theme-green-200, + $theme-green-500, + $theme-green-500, + $theme-light-green-700, + $theme-light-green-700, + $white-light + ); + } + + &.ui-red { + @include gitlab-theme( + $theme-red-100, + $theme-red-200, + $theme-red-500, + $theme-red-700, + $theme-red-800, + $theme-red-900, + $white-light + ); + } + + &.ui-light-red { + @include gitlab-theme( + $theme-light-red-100, + $theme-light-red-200, + $theme-light-red-500, + $theme-light-red-500, + $theme-light-red-700, + $theme-light-red-700, + $white-light + ); + } + + &.ui-dark { + @include gitlab-theme( + $theme-gray-100, + $theme-gray-200, + $theme-gray-500, + $theme-gray-700, + $theme-gray-800, + $theme-gray-900, + $white-light + ); + } + + &.ui-light { @include gitlab-theme( $theme-gray-900, $theme-gray-700, diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 2085e5646ef..5789c3fa1b1 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -139,6 +139,8 @@ } .nav { + flex-wrap: nowrap; + > li:not(.d-none) a { @include media-breakpoint-down(xs) { margin-left: 0; @@ -158,11 +160,12 @@ } .navbar-toggler { + position: relative; right: -10px; border-radius: 0; min-width: 45px; padding: 0; - margin-right: -7px; + margin: $gl-padding-8 -7px $gl-padding-8 0; font-size: 14px; text-align: center; color: currentColor; @@ -186,6 +189,7 @@ display: -webkit-flex; display: flex; padding-right: 10px; + flex-direction: row; } li { @@ -264,6 +268,8 @@ .navbar-sub-nav, .navbar-nav { + align-items: center; + > li { > a:hover, > a:focus { @@ -290,6 +296,10 @@ margin: 8px; } } + + .dropdown-menu { + position: absolute; + } } .navbar-sub-nav { @@ -297,12 +307,6 @@ display: flex; margin: 0 0 0 6px; - .projects-dropdown-menu { - padding: 0; - overflow-y: initial; - max-height: initial; - } - .dropdown-chevron { position: relative; top: -1px; @@ -443,12 +447,18 @@ } .btn-sign-in { - margin-top: 3px; + background-color: $indigo-100; + color: $indigo-900; font-weight: $gl-font-weight-bold; + line-height: 18px; &:hover { background-color: $white-light; } + + @include media-breakpoint-down(xs) { + margin-top: $gl-padding-4; + } } .navbar-nav { @@ -517,7 +527,7 @@ .header-user { .dropdown-menu { width: auto; - min-width: 160px; + min-width: unset; margin-top: 4px; color: $gl-text-color; left: auto; @@ -529,6 +539,10 @@ display: block; } } + + svg { + vertical-align: text-top; + } } } @@ -548,7 +562,7 @@ background: $white-light; border-bottom: 1px solid $white-normal; - .center-logo { + .mx-auto { margin: 8px 0; text-align: center; diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 1d247671761..86de88729ee 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -45,4 +45,9 @@ &.status-box-upcoming { background: $gl-text-color-secondary; } + + &.status-box-milestone { + color: $gl-text-color; + background: $gray-darker; + } } diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 0536c39cee7..52b5f059f20 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -54,10 +54,6 @@ body { &.limit-container-width { max-width: $limited-layout-width; } - - &.limit-container-width-sm { - max-width: $limited-layout-width-sm; - } } .alert-wrapper { @@ -115,9 +111,3 @@ body { .with-performance-bar .layout-page { margin-top: $header-height + $performance-bar-height; } - -.vertical-center { - min-height: 100vh; - display: flex; - align-items: center; -} diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index b893151e4fe..7290a174668 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -61,10 +61,6 @@ padding-top: 0; line-height: 19px; - &.btn.btn-xs { - padding: 2px 5px; - } - &:focus { margin-top: -10px; padding-top: 10px; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index d76cf8f8182..0b645eb811b 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -186,6 +186,7 @@ overflow-y: hidden; -webkit-overflow-scrolling: touch; display: flex; + flex-wrap: nowrap; &::-webkit-scrollbar { display: none; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index a7896cc3fc3..ffb40166c15 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -1,3 +1,7 @@ +.modal-xl { + max-width: 98%; +} + .modal-header { background-color: $modal-body-bg; diff --git a/app/assets/stylesheets/framework/pagination.scss b/app/assets/stylesheets/framework/pagination.scss index d3e013590b6..61d02511ff4 100644 --- a/app/assets/stylesheets/framework/pagination.scss +++ b/app/assets/stylesheets/framework/pagination.scss @@ -1,91 +1,14 @@ .gl-pagination { - text-align: center; - border-top: 1px solid $border-color; - margin: 0; - margin-top: 0; - - .pagination { - padding: 0; - margin: 20px 0; - - a { - cursor: pointer; - } - - .separator, - .separator:hover { - a { - cursor: default; - background-color: $gray-light; - padding: $gl-vert-padding; - } - } + a { + color: inherit; + text-decoration: none; } - - - .gap, - .gap:hover { - background-color: $gray-light; - padding: $gl-vert-padding; - cursor: default; - } -} - -.card > .gl-pagination { - margin: 0; } -/** - * Extra-small screen pagination. - */ -@media (max-width: 320px) { - .gl-pagination { - .first, - .last { - display: none; - } - - .page-item { - display: none; - - &.active { - display: inline; - } - } - } -} - -/** - * Small screen pagination - */ -@include media-breakpoint-down(xs) { - .gl-pagination { - .pagination li a { - padding: 6px 10px; - } - - .page-item { - display: none; - - &.active { - display: inline; - } - } - } -} - -/** - * Medium screen pagination - */ -@media (min-width: map-get($grid-breakpoints, xs)) and (max-width: map-get($grid-breakpoints, sm)) { - .gl-pagination { - .page-item { - display: none; - - &.active, - &.sibling { - display: inline; - } +.page-item { + &.active { + .page-link { + z-index: 3; } } } diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 847fc8c0792..9dbb04e5443 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -197,7 +197,7 @@ flex-flow: row wrap; .nav-controls { - $controls-margin: $btn-xs-side-margin - 2px; + $controls-margin: $btn-margin-5 - 2px; flex: 0 0 100%; &.controls-flex { @@ -230,6 +230,8 @@ } .scrolling-tabs-container { + position: relative; + .merge-request-tabs-container & { overflow: hidden; } @@ -345,7 +347,7 @@ .empty-state .project-item-select-holder.btn-group { float: none; - display: inline-block; + justify-content: center; .btn { // overrides styles applied to plain `.empty-state .btn` diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index a10bd1544c5..339388392df 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -1,5 +1,6 @@ .table-holder { margin: 0; + overflow: auto; } table { @@ -38,6 +39,11 @@ table { &.wide { width: 55%; } + + &.table-th-transparent { + background: none; + color: $gl-text-color-secondary; + } } td { @@ -45,9 +51,91 @@ table { } } } + + &.responsive-table { + @include media-breakpoint-down(sm) { + thead { + display: none; + } + + &, + tbody, + td { + display: block; + } + + td { + color: $gl-text-color-secondary; + } + + tbody td.responsive-table-cell { + padding: $gl-padding 0; + width: 100%; + display: flex; + text-align: right; + align-items: center; + justify-content: space-between; + + &[data-column]::before { + content: attr(data-column); + display: block; + text-align: left; + padding-right: $gl-padding; + color: $gl-text-color-secondary; + } + + &:not([data-column]) { + flex-direction: row-reverse; + } + } + + tr.responsive-table-border-start, + tr.responsive-table-border-end { + display: block; + border: solid $gl-text-color-quaternary; + padding-left: 0; + padding-right: 0; + + > td { + border-color: $gl-text-color-quaternary; + + &, + &:last-child { + padding-left: $gl-padding; + padding-right: $gl-padding; + } + } + } + + tr.responsive-table-border-start { + border-width: 1px 1px 0; + border-radius: $border-radius-default $border-radius-default 0 0; + padding-top: 0; + padding-bottom: 0; + + > td:first-child { + border-top: 0; // always have the <table> top border + } + + > td:last-child { + border-bottom: 1px solid $gl-text-color-quaternary; + } + } + + tr.responsive-table-border-end { + border-width: 0 1px 1px; + border-radius: 0 0 $border-radius-default $border-radius-default; + margin-bottom: 2 * $gl-padding; + + > :last-child { + border-bottom: 0; + } + } + } + } } -.responsive-table { +.responsive-table:not(table) { @include media-breakpoint-down(sm) { th { width: 100%; diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss index 744fd0ff796..7cda674e5c8 100644 --- a/app/assets/stylesheets/framework/terms.scss +++ b/app/assets/stylesheets/framework/terms.scss @@ -11,15 +11,15 @@ padding-top: $gl-padding; } - .panel { - .panel-heading { + .card { + .card-header { display: -webkit-flex; display: flex; align-items: center; justify-content: space-between; line-height: $line-height-base; - .title { + .card-title { display: flex; align-items: center; @@ -34,6 +34,8 @@ .navbar-collapse { padding-right: 0; + flex-grow: 0; + flex-basis: auto; .navbar-nav { margin: 0; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 75c11590547..dfb145debe7 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -4,7 +4,7 @@ padding: 0; &::before { - @include notes-media('max', map-get($grid-breakpoints, xs)) { + @include notes-media('max', map-get($grid-breakpoints, sm)) { background: none; } } @@ -34,7 +34,7 @@ .timeline-entry-inner { position: relative; - @include notes-media('max', map-get($grid-breakpoints, xs)) { + @include notes-media('max', map-get($grid-breakpoints, sm)) { .timeline-icon { display: none; } diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss index d5cc78a6680..20394cc1e52 100644 --- a/app/assets/stylesheets/framework/toggle.scss +++ b/app/assets/stylesheets/framework/toggle.scss @@ -42,6 +42,10 @@ background: none; } + &:focus { + outline: none; + } + .toggle-icon { position: relative; display: block; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 97b821e0cb9..9e77ea03a24 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -114,26 +114,27 @@ font-size: 0.95em; } + blockquote, .blockquote { color: $gl-grayish-blue; font-size: inherit; padding: 8px 24px; margin: 16px 0; border-left: 3px solid $white-dark; - } - .blockquote:dir(rtl) { - border-left: 0; - border-right: 3px solid $white-dark; - } + &:dir(rtl) { + border-left: 0; + border-right: 3px solid $white-dark; + } - .blockquote p { - color: $gl-grayish-blue !important; - font-size: inherit; - line-height: 1.5; + p { + color: $gl-grayish-blue !important; + font-size: inherit; + line-height: 1.5; - &:last-child { - margin: 0; + &:last-child { + margin: 0; + } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 946223cfff0..7808f6d3a25 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -99,7 +99,7 @@ $theme-gray-200: #dfdfdf; $theme-gray-300: #cccccc; $theme-gray-400: #bababa; $theme-gray-500: #a7a7a7; -$theme-gray-600: #949494; +$theme-gray-600: #919191; $theme-gray-700: #707070; $theme-gray-800: #4f4f4f; $theme-gray-900: #2e2e2e; @@ -117,6 +117,15 @@ $theme-blue-800: #25496e; $theme-blue-900: #1a3652; $theme-blue-950: #0f2235; +$theme-light-blue-50: #f2f7fc; +$theme-light-blue-100: #ebf1f7; +$theme-light-blue-200: #c9dcf2; +$theme-light-blue-300: #83abd4; +$theme-light-blue-400: #4d86bf; +$theme-light-blue-500: #367cc2; +$theme-light-blue-600: #3771ab; +$theme-light-blue-700: #2261a1; + $theme-green-50: #f2faf6; $theme-green-100: #e4f3ea; $theme-green-200: #c0dfcd; @@ -129,6 +138,29 @@ $theme-green-800: #145d33; $theme-green-900: #0d4524; $theme-green-950: #072d16; +$theme-light-green-700: #156b39; + +$theme-red-50: #fcf4f2; +$theme-red-100: #fae9e6; +$theme-red-200: #ebcac5; +$theme-red-300: #d99b91; +$theme-red-400: #b0655a; +$theme-red-500: #ad4a3b; +$theme-red-600: #9e4133; +$theme-red-700: #912f20; +$theme-red-800: #78291d; +$theme-red-900: #691a16; +$theme-red-950: #36140f; + +$theme-light-red-50: #fff6f5; +$theme-light-red-100: #fae2de; +$theme-light-red-200: #f7d5d0; +$theme-light-red-300: #d9796a; +$theme-light-red-400: #cf604e; +$theme-light-red-500: #c24b38; +$theme-light-red-600: #b03927; +$theme-light-red-700: #a62e21; + $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); $almost-black: #242424; @@ -142,11 +174,6 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor); $border-gray-dark: darken($white-normal, $darken-border-factor); /* - * Override Bootstrap 4 variables - */ -$secondary: $gray-light; - -/* * UI elements */ $border-color: #e5e5e5; @@ -164,7 +191,7 @@ $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; $gl-text-color: #2e2e2e; $gl-text-color-secondary: #707070; -$gl-text-color-tertiary: #949494; +$gl-text-color-tertiary: #919191; $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: rgba(255, 255, 255, 1); $gl-text-color-secondary-inverted: rgba(255, 255, 255, 0.85); @@ -206,7 +233,7 @@ $md-area-border: #ddd; /* * Code */ -$code_font_size: 12px; +$code_font_size: 90%; $code_line_height: 1.6; /* @@ -217,10 +244,11 @@ $tooltip-font-size: 12px; /* * Padding */ -$gl-padding-24: 24px; -$gl-padding: 16px; -$gl-padding-8: 8px; $gl-padding-4: 4px; +$gl-padding-8: 8px; +$gl-padding: 16px; +$gl-padding-24: 24px; +$gl-padding-32: 32px; $gl-col-padding: 15px; $gl-input-padding: 10px; $gl-vert-padding: 6px; @@ -238,7 +266,6 @@ $header-height: 40px; $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; -$limited-layout-width-sm: 790px; $container-text-max-width: 540px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; @@ -251,7 +278,7 @@ $active-item-blue: $blue-500; $layout-link-gray: #7e7c7c; $btn-side-margin: 10px; $btn-sm-side-margin: 7px; -$btn-xs-side-margin: 5px; +$btn-margin-5: 5px; $issue-status-expired: $orange-500; $issuable-sidebar-color: $gl-text-color-secondary; $sidebar-block-hover-color: #ebebeb; @@ -408,6 +435,22 @@ $badge-bg: rgba(0, 0, 0, 0.07); $badge-color: $gl-text-color-secondary; /* +* Pagination +*/ +$pagination-padding-y: 6px; +$pagination-padding-x: 16px; +$pagination-line-height: 20px; +$pagination-border-color: $border-color; +$pagination-active-bg: $blue-600; +$pagination-active-border-color: $blue-600; +$pagination-hover-bg: $blue-50; +$pagination-hover-border-color: $border-color; +$pagination-hover-color: $gl-text-color; +$pagination-disabled-color: #cdcdcd; +$pagination-disabled-bg: $gray-light; +$pagination-disabled-border-color: $border-color; + +/* * Status icons */ $status-icon-size: 22px; @@ -776,3 +819,19 @@ $modal-body-height: 134px; Prometheus */ $prometheus-table-row-highlight-color: $theme-gray-100; + +$priority-label-empty-state-width: 114px; + +/* + * Override Bootstrap 4 variables + */ + +$secondary: $gray-light; +$input-disabled-bg: $gray-light; +$input-border-color: $theme-gray-200; +$input-color: $gl-text-color; +$font-family-sans-serif: $regular_font; +$font-family-monospace: $monospace_font; +$input-line-height: 20px; +$btn-line-height: 20px; +$table-accent-bg: $gray-light; diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 514fac82b1e..161943766d4 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -93,6 +93,10 @@ font-size: 12px; } } + + svg { + vertical-align: text-top; + } } .light-well { diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index f0ac9b46f91..604f806dc58 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -111,7 +111,9 @@ $dark-il: #de935f; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index eba7919ada9..8e2720511da 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -111,7 +111,9 @@ $monokai-gi: #a6e22e; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index ba53ef0352b..cd1f0f6650f 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -115,7 +115,9 @@ $solarized-dark-il: #2aa198; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include dark-diff-match-line; } diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index e9fccf1b58a..09c3ea36414 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -122,7 +122,9 @@ $solarized-light-il: #2aa198; // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + &.old-nonewline .line_content, + &.new-nonewline .line_content { @include matchLine; } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 8cc5252648d..90a5250c247 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -102,7 +102,9 @@ pre.code, // Diff line .line_holder { - &.match .line_content { + &.match .line_content, + .new-nonewline.line_content, + .old-nonewline.line_content { @include matchLine; } diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss index b5eda79e5ed..1835c4364d3 100644 --- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss +++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss @@ -138,6 +138,7 @@ pre { margin: 0; } +blockquote, .blockquote { color: $gl-grayish-blue; padding: 0 0 0 15px; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 1c3d312f7ac..750d2c8b990 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -80,6 +80,7 @@ overflow-x: scroll; white-space: nowrap; min-height: 200px; + display: flex; @include media-breakpoint-only(sm) { height: calc(100vh - #{$issue-board-list-difference-sm}); @@ -110,17 +111,15 @@ .board { display: inline-block; - width: calc(85vw - 15px); + flex: 1; + min-width: 300px; + max-width: 400px; height: 100%; padding-right: ($gl-padding / 2); padding-left: ($gl-padding / 2); white-space: normal; vertical-align: top; - @include media-breakpoint-up(sm) { - width: 400px; - } - &.is-expandable { .board-header { cursor: pointer; @@ -128,6 +127,8 @@ } &.is-collapsed { + flex: none; + min-width: 0; width: 50px; .board-header { @@ -282,9 +283,6 @@ box-shadow: 0 1px 2px $issue-boards-card-shadow; list-style: none; - // as a fallback, hide overflow content so that dragging and dropping still works - overflow: hidden; - &:not(:last-child) { margin-bottom: 5px; } @@ -292,10 +290,6 @@ &.is-active, &.is-active .board-card-assignee:hover a { background-color: $row-hover; - - &:first-child:not(:only-child) { - box-shadow: -10px 0 10px 1px $row-hover; - } } .badge { diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 9ee02ca1d83..f030189af06 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -12,26 +12,22 @@ @keyframes blinking-dots { 0% { background-color: rgba($white-light, 1); - box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 0.2); + box-shadow: 12px 0 0 0 rgba($white-light, 0.2), 24px 0 0 0 rgba($white-light, 0.2); } 25% { background-color: rgba($white-light, 0.4); - box-shadow: 12px 0 0 0 rgba($white-light, 2), - 24px 0 0 0 rgba($white-light, 0.2); + box-shadow: 12px 0 0 0 rgba($white-light, 2), 24px 0 0 0 rgba($white-light, 0.2); } 75% { background-color: rgba($white-light, 0.4); - box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 1); + box-shadow: 12px 0 0 0 rgba($white-light, 0.2), 24px 0 0 0 rgba($white-light, 1); } 100% { background-color: rgba($white-light, 1); - box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 0.2); + box-shadow: 12px 0 0 0 rgba($white-light, 0.2), 24px 0 0 0 rgba($white-light, 0.2); } } @@ -71,10 +67,15 @@ .bash { display: block; } + + &.build-trace-rounded { + border-radius: $border-radius-base; + } } .top-bar { height: 35px; + min-height: 35px; background: $gray-light; border: 1px solid $border-color; color: $gl-text-color; diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 3e4d123242c..56beb7718a4 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -13,6 +13,10 @@ max-width: 100%; } +.clusters-error-alert { + width: 100%; +} + .clusters-container { .nav-bar-right { padding: $gl-padding-top $gl-padding; diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index a4ca82de90e..f75be4e01cd 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -135,10 +135,10 @@ } .text-expander { - display: inline-block; + display: inline-flex; background: $white-light; color: $gl-text-color-secondary; - padding: 0 4px; + padding: 1px $gl-padding-4; cursor: pointer; border: 1px solid $border-gray-dark; border-radius: $border-radius-default; @@ -180,6 +180,11 @@ .commit-content { padding-right: 10px; white-space: normal; + + .commit-title { + display: flex; + align-items: center; + } } .commit-actions { @@ -193,6 +198,10 @@ display: inline-flex; } + .ci-status-icon svg { + vertical-align: text-bottom; + } + > .ci-status-link, > .btn, > .commit-sha-group { @@ -249,16 +258,19 @@ .generic_commit_status { a, button { - color: $gl-text-color; vertical-align: baseline; } - a.autodevops-badge { - color: $white-light; - } + a { + color: $gl-text-color; - a.autodevops-link { - color: $gl-link-color; + &.autodevops-badge { + color: $white-light; + } + + &.autodevops-link { + color: $gl-link-color; + } } .commit-row-description { diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index 7b36bcb3c7d..2e007c52592 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -23,7 +23,8 @@ position: relative; line-height: 35px; display: flex; - flex-grow: 1; + flex: 1 1; + min-width: 0; @include media-breakpoint-up(sm) { padding-left: 0; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index f06c9dcdf8c..a90a9c6e486 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -14,8 +14,8 @@ background-color: $gray-normal; } - .diff-toggle-caret { - padding-right: 6px; + svg { + vertical-align: text-bottom; } } @@ -24,6 +24,10 @@ color: $gl-text-color; border-radius: 0 0 3px 3px; + .code { + padding: 0; + } + .unfold { cursor: pointer; } @@ -61,6 +65,7 @@ .diff-line-num { width: 50px; + position: relative; a { transition: none; @@ -77,6 +82,12 @@ span { white-space: pre-wrap; + + &.context-cell { + display: inline-block; + width: 100%; + height: 100%; + } } .line { @@ -189,8 +200,22 @@ img { border: 1px solid $white-light; - background-image: linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%), - linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%); + background-image: linear-gradient( + 45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100% + ), + linear-gradient( + 45deg, + $border-color 25%, + transparent 25%, + transparent 75%, + $border-color 75%, + $border-color 100% + ); background-size: 10px 10px; background-position: 0 0, 5px 5px; max-width: 100%; @@ -395,6 +420,69 @@ .line_content { white-space: pre-wrap; } + + .diff-file-container { + .frame.deleted { + border: 0; + background-color: inherit; + + .image_file img { + border: 1px solid $deleted; + } + } + + .frame.added { + border: 0; + background-color: inherit; + + .image_file img { + border: 1px solid $added; + } + } + + .swipe.view, + .onion-skin.view { + .swipe-wrap { + top: 0; + right: 0; + } + + .frame.deleted { + top: 0; + right: 0; + } + + .swipe-bar { + top: 0; + + .top-handle { + top: -14px; + left: -7px; + } + + .bottom-handle { + bottom: -14px; + left: -7px; + } + } + + .file-container { + display: inline-block; + + .file-content { + padding: 0; + + img { + max-width: none; + } + } + } + } + + .onion-skin.view .controls { + bottom: -25px; + } + } } .file-content .diff-file { @@ -414,6 +502,10 @@ border-bottom: 0; } +.merge-request-details .file-content.image_file img { + max-height: 50vh; +} + .diff-stats-summary-toggler { padding: 0; background-color: transparent; @@ -536,7 +628,7 @@ margin-right: 0; border-color: $white-light; cursor: pointer; - transition: all .1s ease-out; + transition: all 0.1s ease-out; @for $i from 1 through 4 { &:nth-child(#{$i}) { @@ -563,7 +655,7 @@ height: 24px; border-radius: 50%; padding: 0; - transition: transform .1s ease-out; + transition: transform 0.1s ease-out; z-index: 100; .collapse-icon { @@ -600,21 +692,22 @@ } @include media-breakpoint-up(sm) { - position: -webkit-sticky; - position: sticky; top: 24px; background-color: $white-light; - z-index: 190; &.diff-files-changed-merge-request { - top: 76px; + position: sticky; + top: 90px; + z-index: 200; + margin: $gl-padding 0; + padding: 0; } &.is-stuck { padding-top: 0; padding-bottom: 0; + border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; - transform: translateY(16px); .diff-stats-additions-deletions-expanded, .inline-parallel-buttons { @@ -644,6 +737,10 @@ max-width: 560px; width: 100%; z-index: 150; + min-height: $dropdown-min-height; + max-height: $dropdown-max-height; + overflow-y: auto; + margin-bottom: 0; @include media-breakpoint-up(sm) { left: $gl-padding; @@ -708,11 +805,35 @@ width: 100%; height: 10px; background-color: $white-light; - background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), - linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), - linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), - linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%); - background-position: 5px 5px,0 5px,0 5px,5px 5px; + background-image: linear-gradient( + 45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ), + linear-gradient( + 225deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ), + linear-gradient( + 135deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ), + linear-gradient( + -45deg, + transparent, + transparent 73%, + $diff-jagged-border-gradient-color 75%, + $white-light 80% + ); + background-position: 5px 5px, 0 5px, 0 5px, 5px 5px; background-size: 10px 10px; background-repeat: repeat; } @@ -750,11 +871,16 @@ .frame.click-to-comment { position: relative; cursor: image-url('illustrations/image_comment_light_cursor.svg') - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + auto; // Retina cursor - cursor: -webkit-image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) - $image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; + cursor: -webkit-image-set( + image-url('illustrations/image_comment_light_cursor.svg') 1x, + image-url('illustrations/image_comment_light_cursor@2x.svg') 2x + ) + $image-comment-cursor-left-offset $image-comment-cursor-top-offset, + auto; .comment-indicator { position: absolute; @@ -840,7 +966,7 @@ .diff-notes-collapse, .note, - .discussion-reply-holder, { + .discussion-reply-holder { display: none; } diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index cd0d67613c3..06f08ae2215 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -23,7 +23,6 @@ } .btn-group { - > a { color: $gl-text-color-secondary; } @@ -245,6 +244,7 @@ .prometheus-graph { flex: 1 0 auto; min-width: 450px; + max-width: 100%; padding: $gl-padding / 2; h5 { @@ -256,6 +256,17 @@ } } +.prometheus-graph-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $gl-padding-8; + + h5 { + margin: 0; + } +} + .prometheus-graph-cursor { position: absolute; background: $theme-gray-600; diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index c2b42e02eee..05bf5596fb3 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -425,7 +425,7 @@ margin-left: 5px; > .btn { - margin-right: $btn-xs-side-margin; + margin-right: $btn-margin-5; } } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 4aea9740735..f9fd9f1ab8b 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -485,6 +485,15 @@ .sidebar-collapsed-user { padding-bottom: 0; margin-bottom: 10px; + + .author_link { + padding-left: 0; + + .avatar { + position: static; + margin: 0; + } + } } .issuable-header-btn { @@ -689,6 +698,8 @@ font-size: 14px; line-height: 24px; align-self: center; + overflow: hidden; + text-overflow: ellipsis; } .js-issuable-selector-wrap { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index e178371d21f..391dfea0703 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -57,67 +57,6 @@ border-bottom-left-radius: $border-radius-base; } -.label-row { - .label-name { - display: inline-block; - margin-bottom: 10px; - - @include media-breakpoint-up(sm) { - width: 200px; - margin-left: $gl-padding * 2; - margin-bottom: 0; - } - - .badge { - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; - } - } - - .label-type { - display: block; - margin-bottom: 10px; - margin-left: 50px; - - @include media-breakpoint-up(sm) { - display: inline-block; - width: 100px; - margin-left: 10px; - margin-bottom: 0; - vertical-align: top; - } - } - - .label-description { - display: block; - margin-bottom: 10px; - - .description-text { - margin-bottom: $gl-padding; - } - - a { - color: $blue-600; - } - - @include media-breakpoint-up(sm) { - display: inline-block; - max-width: 50%; - margin-left: 10px; - margin-bottom: 0; - vertical-align: top; - } - } - - .badge { - padding: 4px $grid-size; - font-size: $label-font-size; - position: relative; - top: ($grid-size / 2); - } -} - .color-label { padding: 0 $grid-size; line-height: 16px; @@ -133,26 +72,31 @@ } .manage-labels-list { - @media(min-width: map-get($grid-breakpoints, md)) { - &.content-list li { - padding: $gl-padding 0; - } - } - > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; - cursor: move; - cursor: -webkit-grab; - cursor: -moz-grab; - - &:active { - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - } + margin-bottom: 5px; + display: flex; + justify-content: space-between; + padding: $gl-padding; + border-radius: $border-radius-default; + border: 1px solid $theme-gray-100; &.sortable-ghost { opacity: 0.3; } + + .prioritized-labels & { + box-shadow: 0 1px 2px $issue-boards-card-shadow; + cursor: move; + cursor: -webkit-grab; + cursor: -moz-grab; + border: 0; + + &:active { + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + } + } } .btn-action { @@ -171,28 +115,11 @@ } } - .dropdown { - @include media-breakpoint-up(sm) { - float: right; - } - } - - @include media-breakpoint-down(xs) { - .dropdown-menu { - min-width: 100%; - } + .color-label { + padding: $gl-padding-4 $grid-size; } } -.draggable-handler { - display: inline-block; - vertical-align: top; - margin: 5px 0; - opacity: 0; - transition: opacity .3s; - color: $gray-darkest; -} - .prioritized-labels { margin-bottom: 30px; @@ -215,22 +142,6 @@ } } -.toggle-priority { - display: inline-block; - vertical-align: top; - - button { - border-color: transparent; - padding: 5px 8px; - vertical-align: top; - font-size: 14px; - - &:hover { - border-color: transparent; - } - } -} - .filtered-labels { font-size: 0; padding: 12px 16px; @@ -284,10 +195,8 @@ } .label-subscribe-button { - @media(min-width: map-get($grid-breakpoints, md)) { - min-width: 105px; - margin-left: $gl-padding; - } + width: 105px; + font-weight: 200; .label-subscribe-button-icon { &[disabled] { @@ -313,7 +222,7 @@ .label-link { display: inline-flex; - vertical-align: top; + vertical-align: text-bottom; &:hover .color-label { text-decoration: underline; @@ -324,3 +233,95 @@ font-size: $label-font-size; } } + +.labels-container { + background-color: $gray-light; + border-radius: $border-radius-default; + padding: $gl-padding $gl-padding-8; +} + +.label-actions-list { + list-style: none; + flex-shrink: 0; + padding: 0; +} + +.label-badge { + color: $theme-gray-900; + font-weight: $gl-font-weight-normal; + padding: $gl-padding-4 $gl-padding-8; + border-radius: $border-radius-default; + font-size: $label-font-size; +} + +.label-badge-blue { + background-color: $theme-blue-100; +} + +.label-badge-gray { + background-color: $theme-gray-100; +} + +.label-links { + list-style: none; + padding: 0; + white-space: nowrap; +} + +.label-link-item { + padding: 0; +} + +.label-list-item { + .content-list &::before, + .content-list &::after { + content: none; + } + + .label-name { + width: 150px; + flex-shrink: 0; + + .badge { + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + } + + .label-description { + flex-grow: 1; + + a { + color: $blue-600; + } + } + + .label { + padding: 4px $grid-size; + font-size: $label-font-size; + position: relative; + top: $gl-padding-4; + } + + .label-action { + color: $theme-gray-800; + cursor: pointer; + + svg { + fill: $theme-gray-800; + } + + &:hover { + color: $blue-600; + + svg { + fill: $blue-600; + } + } + } +} + +.priority-labels-empty-state .svg-content img { + max-width: $priority-label-empty-state-width; +} diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 9914555d309..5fdb2b4a90a 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -121,10 +121,6 @@ background: transparent; border: 0; outline: 0; - - @include media-breakpoint-up(sm) { - right: 160px; - } } .flex-project-members-panel { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 9eceb3e9a33..efd730af558 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -51,12 +51,6 @@ opacity: 0.3; } - &.btn-xs { - line-height: 1; - padding: 5px 10px; - margin-top: 1px; - } - &.dropdown-toggle { .fa { color: inherit; @@ -605,14 +599,12 @@ position: relative; background: $gray-light; color: $gl-text-color; - z-index: 199; .mr-version-menus-container { - display: -webkit-flex; display: flex; - -webkit-align-items: center; align-items: center; padding: 16px; + z-index: 199; } .content-block { @@ -678,6 +670,7 @@ .merge-request-tabs { display: flex; + flex-wrap: nowrap; margin-bottom: 0; padding: 0; } @@ -744,6 +737,10 @@ > *:not(:last-child) { margin-right: .3em; } + + svg { + vertical-align: text-top; + } } .deploy-link { diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index dba83e56d72..46437ce5841 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -3,8 +3,20 @@ } .milestones { + padding: $gl-padding-8; + margin-top: $gl-padding-8; + border-radius: $border-radius-default; + background-color: $theme-gray-100; + .milestone { - padding: 10px 16px; + border: 0; + padding: $gl-padding-top $gl-padding; + border-radius: $border-radius-default; + background-color: $white-light; + + &:not(:last-child) { + margin-bottom: $gl-padding-4; + } h4 { font-weight: $gl-font-weight-bold; @@ -13,6 +25,24 @@ .progress { width: 100%; height: 6px; + margin-bottom: $gl-padding-4; + } + + .milestone-progress { + a { + color: $gl-link-color; + } + } + + .status-box { + font-size: $tooltip-font-size; + margin-top: 0; + margin-right: $gl-padding-4; + + @include media-breakpoint-down(xs) { + line-height: unset; + padding: $gl-padding-4 $gl-input-padding; + } } } } @@ -229,6 +259,10 @@ } } +.milestone-range { + color: $gl-text-color-tertiary; +} + @include media-breakpoint-down(xs) { .milestone-banner-text, .milestone-banner-link { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3b037d066dc..5e5696b1602 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -129,7 +129,7 @@ .icon svg { position: relative; top: 2px; - margin-right: $btn-xs-side-margin; + margin-right: $btn-margin-5; width: $gl-font-size; height: $gl-font-size; fill: $orange-600; @@ -247,22 +247,6 @@ } .discussion-with-resolve-btn { - display: table; - width: 100%; - border-collapse: separate; - table-layout: auto; - - .btn-group { - display: table-cell; - float: none; - width: 1%; - - &:first-child { - width: 100%; - padding-right: 5px; - } - } - .discussion-actions { display: table; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 299eda53140..32d14049067 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -3,9 +3,17 @@ */ @-webkit-keyframes targe3-note { - from { background: $note-targe3-outside; } - 50% { background: $note-targe3-inside; } - to { background: $note-targe3-outside; } + from { + background: $note-targe3-outside; + } + + 50% { + background: $note-targe3-inside; + } + + to { + background: $note-targe3-outside; + } } ul.notes { @@ -33,10 +41,12 @@ ul.notes { .diff-content { overflow: visible; + padding: 0; } } - > li { // .timeline-entry + > li { + // .timeline-entry padding: 0; display: block; position: relative; @@ -153,7 +163,6 @@ ul.notes { } .note-header { - @include notes-media('max', map-get($grid-breakpoints, xs)) { .inline { display: block; @@ -245,7 +254,6 @@ ul.notes { .system-note-commit-list-toggler { color: $gl-link-color; - display: none; padding: 10px 0 0; cursor: pointer; position: relative; @@ -624,20 +632,18 @@ ul.notes { .line_holder .is-over:not(.no-comment-btn) { .add-diff-note { opacity: 1; + z-index: 101; } } .add-diff-note { @include btn-comment-icon; opacity: 0; - margin-top: -2px; margin-left: -55px; position: absolute; + top: 50%; + transform: translateY(-50%); z-index: 10; - - .new & { - margin-top: -10px; - } } .discussion-body, @@ -665,7 +671,6 @@ ul.notes { background-color: $white-light; } - a { color: $gl-link-color; } @@ -716,7 +721,7 @@ ul.notes { .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 6px 10px; + padding: 5px 10px 6px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; @@ -771,3 +776,44 @@ ul.notes { height: auto; } } + +// Vue refactored diff discussion adjustments +.files { + .diff-discussions { + .note-discussion.timeline-entry { + padding-left: 0; + + &:last-child { + border-bottom: 0; + } + + > .timeline-entry-inner { + padding: 0; + + > .timeline-content { + margin-left: 0; + } + + > .timeline-icon { + display: none; + } + } + + .discussion-body { + padding-top: 0; + + .discussion-wrapper { + border-color: transparent; + } + } + } + } + + .diff-comment-form { + display: block; + } + + .add-diff-note svg { + margin-top: 4px; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index f85f66b9c0b..52332ac97dd 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -36,6 +36,7 @@ } .table-holder { + overflow: unset; width: 100%; } @@ -321,18 +322,17 @@ } .build-failures { + th { + border-top: 0; + } + .build-state { padding: 20px 2px; .build-name { - float: right; font-weight: $gl-font-weight-normal; } - .ci-status-icon-failed svg { - vertical-align: middle; - } - .stage { color: $gl-text-color-secondary; font-weight: $gl-font-weight-normal; @@ -344,6 +344,81 @@ border: 0; line-height: initial; } + + .build-trace-row td { + border-top: 0; + border-bottom-width: 1px; + border-bottom-style: solid; + padding-top: 0; + } + + .build-trace { + width: 100%; + text-align: left; + margin-top: $gl-padding; + } + + .build-name { + width: 196px; + + a { + font-weight: $gl-font-weight-bold; + color: $gl-text-color; + text-decoration: none; + + &:focus, + &:hover { + text-decoration: underline; + } + } + } + + .build-actions { + width: 70px; + text-align: right; + } + + .build-stage { + width: 140px; + } + + .ci-status-icon-failed { + padding: 10px 0 10px 12px; + width: 12px + 24px; // padding-left + svg width + } + + .build-icon svg { + width: 24px; + height: 24px; + vertical-align: middle; + } + + .build-state, + .build-trace-row { + > td:last-child { + padding-right: 0; + } + } + + @include media-breakpoint-down(sm) { + td:empty { + display: none; + } + + .ci-table { + margin-top: 2 * $gl-padding; + } + + .build-trace-container { + padding-top: $gl-padding; + padding-bottom: $gl-padding; + } + + .build-trace { + margin-bottom: 0; + margin-top: 0; + } + } } .pipeline-tab-content { @@ -926,10 +1001,10 @@ button.mini-pipeline-graph-dropdown-toggle { /** * Center dropdown menu in mini graph */ - &.dropdown-menu { + .dropdown &.dropdown-menu { transform: translate(-80%, 0); - @media(min-width: map-get($grid-breakpoints, md)) { + @media (min-width: map-get($grid-breakpoints, md)) { transform: translate(-50%, 0); right: auto; left: 50%; diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss index 68d40b56133..a353f301d07 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/pages/profiles/preferences.scss @@ -1,25 +1,3 @@ -@mixin application-theme-preview($color-1, $color-2, $color-3, $color-4) { - .one { - background-color: $color-1; - border-top-left-radius: $border-radius-default; - } - - .two { - background-color: $color-2; - border-top-right-radius: $border-radius-default; - } - - .three { - background-color: $color-3; - border-bottom-left-radius: $border-radius-default; - } - - .four { - background-color: $color-4; - border-bottom-right-radius: $border-radius-default; - } -} - .multi-file-editor-options { label { margin-right: 20px; @@ -38,49 +16,67 @@ .application-theme { label { - margin-right: 20px; + margin: 0 $gl-padding-32 $gl-padding 0; text-align: center; } .preview { font-size: 0; - margin-bottom: 10px; + height: 48px; + border-radius: 4px; + min-width: 112px; + margin-bottom: $gl-padding-8; + + &.ui-indigo { + background-color: $indigo-900; + } - &.indigo { - @include application-theme-preview($indigo-900, $indigo-700, $indigo-800, $indigo-500); + &.ui-light-indigo { + background-color: $indigo-700; } - &.dark { - @include application-theme-preview($theme-gray-900, $theme-gray-700, $theme-gray-800, $theme-gray-600); + &.ui-blue { + background-color: $theme-blue-900; } - &.light { - @include application-theme-preview($theme-gray-600, $theme-gray-200, $theme-gray-400, $theme-gray-100); + &.ui-light-blue { + background-color: $theme-light-blue-700; } - &.blue { - @include application-theme-preview($theme-blue-900, $theme-blue-700, $theme-blue-800, $theme-blue-500); + &.ui-green { + background-color: $theme-green-900; } - &.green { - @include application-theme-preview($theme-green-900, $theme-green-700, $theme-green-800, $theme-green-500); + &.ui-light-green { + background-color: $theme-light-green-700; + } + + &.ui-red { + background-color: $theme-red-900; + } + + &.ui-light-red { + background-color: $theme-light-red-700; + } + + &.ui-dark { + background-color: $theme-gray-900; + } + + &.ui-light { + background-color: $theme-gray-200; } } .preview-row { display: block; } - - .quadrant { - display: inline-block; - height: 50px; - width: 80px; - } } .syntax-theme { label { - margin-right: 20px; + margin-right: $gl-padding-32; + margin-bottom: $gl-padding; text-align: center; .preview { @@ -89,7 +85,6 @@ img { border-radius: 4px; - max-width: 100%; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 22964163e95..aa83e5bdebc 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -354,12 +354,6 @@ min-width: 200px; } -.deploy-keys { - .scrolling-tabs-container { - position: relative; - } -} - .deploy-key { // Ensure that the fingerprint does not overflow on small screens .fingerprint { @@ -503,6 +497,12 @@ &:not(:first-child) { border-top: 1px solid $border-color; } + + .btn-template-icon { + position: absolute; + left: $gl-padding; + top: $gl-padding; + } } .template-title { @@ -520,12 +520,6 @@ } } - svg { - position: absolute; - left: $gl-padding; - top: $gl-padding; - } - .project-fields-form { display: none; @@ -536,34 +530,23 @@ } .template-input-group { - position: relative; - - @include media-breakpoint-up(sm) { - display: flex; - } - - .input-group-prepend, - .input-group-append { + .input-group-prepend { flex: 1; - text-align: left; - padding-left: ($gl-padding * 3); - background-color: $white-light; } - .selected-template { - line-height: 20px; + .input-group-text { + width: 100%; + background-color: $white-light; } .selected-icon { + padding-right: $gl-padding; + svg { display: none; top: 7px; height: 20px; width: 20px; - - &.active { - display: block; - } } } } @@ -875,7 +858,6 @@ pre.light-well { .git-clone-holder { width: 380px; - height: 28px; .btn-clipboard { border: 1px solid $border-color; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 6bbcb15329c..3c24aaa65e8 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -23,6 +23,7 @@ margin-top: 0; border-top: 1px solid $white-dark; padding-bottom: $ide-statusbar-height; + color: $gl-text-color; &.is-collapsed { .ide-file-list { @@ -45,12 +46,8 @@ .file { cursor: pointer; - &.file-open { - background: $white-normal; - } - &.file-active { - font-weight: $gl-font-weight-bold; + background: $theme-gray-100; } .ide-file-name { @@ -58,7 +55,9 @@ white-space: nowrap; text-overflow: ellipsis; max-width: inherit; - line-height: 22px; + line-height: 16px; + display: inline-block; + height: 18px; svg { vertical-align: middle; @@ -86,12 +85,14 @@ .ide-new-btn { display: none; + + .btn { + padding: 2px 5px; + } } &:hover, &:focus { - background: $white-normal; - .ide-new-btn { display: block; } @@ -183,7 +184,7 @@ svg { position: relative; - top: -1px; + top: -2px; } .ide-file-changed-icon { @@ -281,8 +282,8 @@ } .margin { - background-color: $gray-light; - border-right: 1px solid $white-normal; + background-color: $white-light; + border-right: 1px solid $theme-gray-100; .line-insert { border-right: 1px solid $line-added-dark; @@ -303,6 +304,15 @@ .multi-file-editor-holder { height: 100%; min-height: 0; + + &.is-readonly, + .editor.original { + .monaco-editor, + .monaco-editor-background, + .monaco-editor .inputarea.ime-input { + background-color: $theme-gray-50; + } + } } .preview-container { @@ -335,7 +345,6 @@ img { max-width: 90%; - max-height: 90%; } .isZoomable { @@ -458,9 +467,9 @@ width: auto; margin-right: 0; - a:hover, - a:focus { - text-decoration: none; + > a, + > button { + height: 60px; } } @@ -541,32 +550,12 @@ margin-right: -$grid-size; min-height: 60px; - .multi-file-commit-list-item { - margin-left: 0; - margin-right: 0; - } - &.form-text.text-muted { margin-left: 0; right: 0; } } -.multi-file-commit-list-item { - .multi-file-discard-btn { - display: none; - margin-top: -2px; - margin-left: auto; - color: $gl-link-color; - } - - &:hover { - .multi-file-discard-btn { - display: flex; - } - } -} - .multi-file-addition, .multi-file-addition-solid { color: $green-500; @@ -596,7 +585,7 @@ } } -.multi-file-commit-list-item, +.multi-file-commit-list-path, .ide-file-list .file { display: flex; align-items: center; @@ -608,16 +597,20 @@ &:hover, &:focus { - background: $white-normal; + background: $theme-gray-100; + } + + &:active { + background: $theme-gray-200; } } .multi-file-commit-list-path { - padding: 0; - background: none; - border: 0; - text-align: left; - width: 100%; + cursor: pointer; + + &.is-active { + background-color: $white-normal; + } &:hover, &:focus { @@ -632,17 +625,23 @@ } .multi-file-commit-list-file-path { - @include str-truncated(100%); - - &:hover { - text-decoration: underline; - } + @include str-truncated(calc(100% - 30px)); &:active { text-decoration: none; } } +.multi-file-discard-btn { + top: 4px; + right: 8px; + bottom: 4px; + + svg { + top: 0; + } +} + .multi-file-commit-form { position: relative; background-color: $white-light; @@ -718,9 +717,17 @@ } .ide-new-btn { + .btn { + padding-top: 3px; + padding-bottom: 3px; + } + + .dropdown { + display: flex; + } + .dropdown-toggle svg { - margin-top: -2px; - margin-bottom: 2px; + top: 0; } .dropdown-menu { @@ -829,18 +836,20 @@ } .ide-staged-action-btn { - margin-left: auto; - line-height: 22px; + width: 22px; + margin-left: -1px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + > svg { + top: 0; + } } .ide-commit-file-count { min-width: 22px; - margin-left: auto; background-color: $gray-light; - border-radius: $border-radius-default; border: 1px solid $white-dark; - line-height: 20px; - text-align: center; } .ide-commit-radios { @@ -877,6 +886,7 @@ border-top: 1px solid transparent; border-bottom: 1px solid transparent; outline: 0; + cursor: pointer; svg { margin: 0 auto; @@ -1120,7 +1130,12 @@ .ide-context-header { .avatar { - flex: 0 0 40px; + flex: 0 0 38px; + } + + .ide-merge-requests-dropdown.dropdown-menu { + width: 385px; + max-height: initial; } } @@ -1130,11 +1145,20 @@ .sidebar-context-title { white-space: nowrap; } + + .ide-sidebar-branch-title { + min-width: 50px; + } } .ide-external-link { + position: relative; + svg { display: none; + position: absolute; + top: 2px; + right: -$gl-padding; } &:hover, @@ -1165,6 +1189,8 @@ display: flex; flex-direction: column; height: 100%; + margin-top: -$grid-size; + margin-bottom: -$grid-size; .empty-state { margin-top: auto; @@ -1181,6 +1207,17 @@ margin: 0; } } + + .build-trace, + .top-bar { + margin-left: -$gl-padding; + } + + &.build-page .top-bar { + top: 0; + font-size: 12px; + border-top-right-radius: $border-radius-default; + } } .ide-pipeline-list { @@ -1189,7 +1226,7 @@ } .ide-pipeline-header { - min-height: 50px; + min-height: 55px; padding-left: $gl-padding; padding-right: $gl-padding; @@ -1209,8 +1246,7 @@ .ci-status-icon { display: flex; justify-content: center; - height: 20px; - margin-top: -2px; + min-width: 24px; overflow: hidden; } } @@ -1240,3 +1276,56 @@ overflow: hidden; text-overflow: ellipsis; } + +.ide-job-header { + min-height: 60px; +} + +.ide-merge-requests-dropdown { + .nav-links li { + width: 50%; + padding-left: 0; + padding-right: 0; + + a { + text-align: center; + + &:not(.active) { + background-color: $gray-light; + } + } + } + + .dropdown-input { + padding-left: $gl-padding; + padding-right: $gl-padding; + + .fa { + right: 26px; + } + } + + .btn-link { + padding-top: $gl-padding; + padding-bottom: $gl-padding; + } +} + +.ide-merge-request-current-icon { + min-width: 18px; +} + +.ide-merge-requests-empty { + height: 230px; +} + +.ide-merge-requests-dropdown-content { + min-height: 230px; + max-height: 470px; +} + +.ide-merge-request-project-path { + font-size: 12px; + line-height: 16px; + color: $gl-text-color-secondary; +} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index a35c4ff7c80..2d66f336076 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -18,7 +18,8 @@ .file-finder-input:hover, .issuable-search-form:hover, .search-text-input:hover, -.form-control:hover { +.form-control:hover, +:not[readonly] { border-color: lighten($dropdown-input-focus-border, 20%); box-shadow: 0 0 4px lighten($search-input-focus-shadow-color, 20%); } @@ -28,7 +29,7 @@ input[type="checkbox"]:hover { } .search { - margin: 4px 8px 0; + margin: 0 8px; form { @extend .form-control; @@ -113,7 +114,7 @@ input[type="checkbox"]:hover { } .dropdown-content { - max-height: 302px; + max-height: none; } } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 2b3773eebad..e264b06c4b2 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -52,7 +52,7 @@ .settings-content { max-height: 1px; - overflow-y: scroll; + overflow-y: hidden; padding-right: 110px; animation: collapseMaxHeight 300ms ease-out; // Keep the section from expanding when we scroll over it @@ -102,10 +102,6 @@ .form-text.text-muted { margin-top: 0; } - - .label-light { - margin-bottom: 0; - } } .settings-list-icon { @@ -131,12 +127,9 @@ color: $gl-danger; } -.service-settings .form-control-label { - padding-top: 0; -} - .integration-settings-form { - .card.card-body { + .card.card-body, + .info-well { padding: $gl-padding / 2; box-shadow: none; } @@ -174,7 +167,7 @@ .option-description, .option-disabled-reason { - margin-left: 45px; + margin-left: 30px; color: $project-option-descr-color; } @@ -262,25 +255,12 @@ } } -.modal-doorkeepr-auth, -.doorkeeper-app-form { - .scope-description { - color: $theme-gray-700; - } -} - .modal-doorkeepr-auth { .modal-body { padding: $gl-padding; } } -.doorkeeper-app-form { - .scope-description { - margin: 0 0 5px 17px; - } -} - .deprecated-service { cursor: default; } @@ -296,7 +276,8 @@ } .btn-clipboard { - margin-left: 5px; + background-color: $white-light; + border: 1px solid $theme-gray-200; } .deploy-token-help-block { diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index a355e2dee24..777fdb3581e 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -16,3 +16,12 @@ .registry-placeholder { min-height: 60px; } + +.auto-devops-card { + margin-bottom: $gl-vert-padding; + + > .card-body { + border-radius: $card-border-radius; + padding: $gl-padding $gl-padding-24; + } +} diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 06ef58531d7..7a93c4dfa28 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -7,7 +7,6 @@ top: 0; width: 100%; z-index: 2000; - overflow-x: hidden; height: $performance-bar-height; background: $black; @@ -15,6 +14,7 @@ color: $perf-bar-text; select { + color: $perf-bar-text; width: 200px; } @@ -81,7 +81,7 @@ .view { margin-right: 15px; - float: left; + flex-shrink: 0; &:last-child { margin-right: 0; @@ -106,12 +106,12 @@ } .performance-bar-modal { - .modal-footer { - display: none; + .modal-body { + padding: 0; } - .modal-dialog { - width: 860px; + .modal-footer { + display: none; } } } diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 90ccd4abd90..bb10928a037 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -22,9 +22,9 @@ header, nav, -nav.main-nav, nav.navbar-collapse, nav.navbar-collapse.collapse, +.nav-sidebar, .profiler-results, .tree-ref-holder, .tree-holder .breadcrumb, @@ -38,7 +38,8 @@ ul.notes-form, .edit-link, .note-action-button, .right-sidebar, -.flash-container { +.flash-container, +#js-peek { display: none !important; } diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb index ea302f17d16..9aaec905734 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/appearances_controller.rb @@ -41,6 +41,13 @@ class Admin::AppearancesController < Admin::ApplicationController redirect_to admin_appearances_path, notice: 'Header logo was succesfully removed.' end + def favicon + @appearance.remove_favicon! + @appearance.save + + redirect_to admin_appearances_path, notice: 'Favicon was succesfully removed.' + end + private # Use callbacks to share common setup or constraints between actions. @@ -61,6 +68,8 @@ class Admin::AppearancesController < Admin::ApplicationController logo_cache header_logo header_logo_cache + favicon + favicon_cache new_project_guidelines updated_by ] diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index cdfe3d6ab1e..9723e400574 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -52,7 +52,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController private def set_application_setting - @application_setting = ApplicationSetting.current_without_cache + @application_setting = Gitlab::CurrentSettings.current_application_settings end def application_setting_params diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 001f6520093..96b7bc65ac9 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -72,10 +72,10 @@ class Admin::GroupsController < Admin::ApplicationController end def group_params - params.require(:group).permit(group_params_ce) + params.require(:group).permit(allowed_group_params) end - def group_params_ce + def allowed_group_params [ :avatar, :description, diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index 2b47819303e..fb788c47ef1 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -9,7 +9,7 @@ class Admin::HooksController < Admin::ApplicationController end def create - @hook = SystemHook.new(hook_params) + @hook = SystemHook.new(hook_params.to_h) if @hook.save redirect_to admin_hooks_path, notice: 'Hook was successfully created.' diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index bfeb5a2d097..653f3dfffc4 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -187,10 +187,10 @@ class Admin::UsersController < Admin::ApplicationController end def user_params - params.require(:user).permit(user_params_ce) + params.require(:user).permit(allowed_user_params) end - def user_params_ce + def allowed_user_params [ :access_level, :avatar, diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index db8a8cdc0d2..21cc6dfdd16 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -27,7 +27,7 @@ class ApplicationController < ActionController::Base after_action :set_page_title_header, if: -> { request.format == :json } - protect_from_forgery with: :exception + protect_from_forgery with: :exception, prepend: true helper_method :can? helper_method :import_sources_enabled?, :github_import_enabled?, :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled? @@ -91,6 +91,10 @@ class ApplicationController < ActionController::Base payload[:user_id] = logged_user.try(:id) payload[:username] = logged_user.try(:username) end + + if response.status == 422 && response.body.present? && response.content_type == 'application/json'.freeze + payload[:response] = response.body + end end # Controllers such as GitHttpController may use alternative methods @@ -130,12 +134,17 @@ class ApplicationController < ActionController::Base end def access_denied!(message = nil) + # If we display a custom access denied message to the user, we don't want to + # hide existence of the resource, rather tell them they cannot access it using + # the provided message + status = message.present? ? :forbidden : :not_found + respond_to do |format| - format.any { head :not_found } + format.any { head status } format.html do render "errors/access_denied", layout: "errors", - status: 404, + status: status, locals: { message: message } end end @@ -275,8 +284,10 @@ class ApplicationController < ActionController::Base return unless current_user return if current_user.terms_accepted? + message = _("Please accept the Terms of Service before continuing.") + if sessionless_user? - render_403 + access_denied!(message) else # Redirect to the destination if the request is a get. # Redirect to the source if it was a post, so the user can re-submit after @@ -287,7 +298,7 @@ class ApplicationController < ActionController::Base URI(request.referer).path if request.referer end - flash[:notice] = _("Please accept the Terms of Service before continuing.") + flash[:notice] = message redirect_to terms_path(redirect: redirect_path), status: :found end end diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index 381fd4d7508..e8b5934f2a9 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -56,8 +56,12 @@ module Boards private + def list_creation_attrs + %i[label_id] + end + def list_params - params.require(:list).permit(:label_id) + params.require(:list).permit(list_creation_attrs) end def move_params @@ -65,11 +69,15 @@ module Boards end def serialize_as_json(resource) - resource.as_json( + resource.as_json(serialization_attrs) + end + + def serialization_attrs + { only: [:id, :list_type, :position], methods: [:title], label: true - ) + } end end end diff --git a/app/controllers/concerns/internal_redirect.rb b/app/controllers/concerns/internal_redirect.rb index 7409b2e89a5..10b9852e329 100644 --- a/app/controllers/concerns/internal_redirect.rb +++ b/app/controllers/concerns/internal_redirect.rb @@ -23,6 +23,10 @@ module InternalRedirect nil end + def sanitize_redirect(url_or_path) + safe_redirect_path(url_or_path) || safe_redirect_path_for_url(url_or_path) + end + def host_allowed?(uri) uri.host == request.host && uri.port == request.port diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index c925b4aada5..ba510968684 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -7,6 +7,19 @@ module IssuableActions before_action :authorize_admin_issuable!, only: :bulk_update end + def permitted_keys + [ + :issuable_ids, + :assignee_id, + :milestone_id, + :state_event, + :subscription_event, + label_ids: [], + add_label_ids: [], + remove_label_ids: [] + ] + end + def show respond_to do |format| format.html @@ -77,7 +90,7 @@ module IssuableActions end def discussions - notes = issuable.notes + notes = issuable.discussion_notes .inc_relations_for_view .includes(:noteable) .fresh @@ -140,24 +153,15 @@ module IssuableActions end def bulk_update_params - permitted_keys = [ - :issuable_ids, - :assignee_id, - :milestone_id, - :state_event, - :subscription_event, - label_ids: [], - add_label_ids: [], - remove_label_ids: [] - ] + permitted_keys_array = permitted_keys.dup if resource_name == 'issue' - permitted_keys << { assignee_ids: [] } + permitted_keys_array << { assignee_ids: [] } else - permitted_keys.unshift(:assignee_id) + permitted_keys_array.unshift(:assignee_id) end - params.require(:update).permit(permitted_keys) + params.require(:update).permit(permitted_keys_array) end def resource_name diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index ca1b80a36a0..2ef2ee76855 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -95,12 +95,7 @@ module IssuableCollections elsif @group @filter_params[:group_id] = @group.id @filter_params[:include_subgroups] = true - else - # TODO: this filter ignore issues/mr created in public or - # internal repos where you are not a member. Enable this filter - # or improve current implementation to filter only issues you - # created or assigned or mentioned - # @filter_params[:authorized_only] = true + @filter_params[:use_cte_for_search] = true end @filter_params.permit(finder_type.valid_params) diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index b6eb7d292fc..9d58656773d 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -1,6 +1,7 @@ module IssuesAction extend ActiveSupport::Concern include IssuableCollections + include IssuesCalendar # rubocop:disable Gitlab/ModuleWithInstanceVariables def issues @@ -17,18 +18,9 @@ module IssuesAction end # rubocop:enable Gitlab/ModuleWithInstanceVariables - # rubocop:disable Gitlab/ModuleWithInstanceVariables def issues_calendar - @issues = issuables_collection - .non_archived - .with_due_date - .limit(100) - - respond_to do |format| - format.ics { response.headers['Content-Disposition'] = 'inline' } - end + render_issues_calendar(issuables_collection) end - # rubocop:enable Gitlab/ModuleWithInstanceVariables private diff --git a/app/controllers/concerns/issues_calendar.rb b/app/controllers/concerns/issues_calendar.rb new file mode 100644 index 00000000000..671a204621d --- /dev/null +++ b/app/controllers/concerns/issues_calendar.rb @@ -0,0 +1,24 @@ +module IssuesCalendar + extend ActiveSupport::Concern + + # rubocop:disable Gitlab/ModuleWithInstanceVariables + def render_issues_calendar(issuables) + @issues = issuables + .non_archived + .with_due_date + .limit(100) + + respond_to do |format| + format.ics do + # NOTE: with text/calendar as Content-Type, the browser always downloads + # the content as a file (even ignoring the Content-Disposition + # header). We want to display the content inline when accessed + # from GitLab, similarly to the RSS feed. + if request.referer&.start_with?(::Settings.gitlab.base_url) + response.headers['Content-Type'] = 'text/plain' + end + end + end + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables +end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 0c34e49206a..fe9a030cdf2 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -237,10 +237,6 @@ module NotesActions def use_note_serializer? return false if params['html'] - if noteable.is_a?(MergeRequest) - cookies[:vue_mr_discussions] == 'true' - else - noteable.discussions_rendered_on_frontend? - end + noteable.discussions_rendered_on_frontend? end end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index b9b9b6e4e88..16374146ae4 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -1,8 +1,14 @@ module UploadsActions + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize include SendFileUpload - UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo).freeze + UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze + + included do + prepend_before_action :set_html_format, only: :show + end def create link_to_file = UploadService.new(model, params[:file], uploader_class).execute @@ -31,11 +37,23 @@ module UploadsActions disposition = uploader.image_or_video? ? 'inline' : 'attachment' + uploaders = [uploader, *uploader.versions.values] + uploader = uploaders.find { |version| version.filename == params[:filename] } + + return render_404 unless uploader + send_upload(uploader, attachment: uploader.filename, disposition: disposition) end private + # Explicitly set the format. + # Otherwise rails 5 will set it from a file extension. + # See https://github.com/rails/rails/commit/84e8accd6fb83031e4c27e44925d7596655285f7#diff-2b8f2fbb113b55ca8e16001c393da8f1 + def set_html_format + request.format = :html + end + def uploader_class raise NotImplementedError end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 68d328fa797..ff133001b84 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -54,7 +54,7 @@ class DashboardController < Dashboard::ApplicationController return unless @no_filters_set respond_to do |format| - format.html + format.html { render } format.atom { head :bad_request } end end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 00000000000..0a1cf169aca --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,45 @@ +class GraphqlController < ApplicationController + # Unauthenticated users have access to the API for public data + skip_before_action :authenticate_user! + + before_action :check_graphql_feature_flag! + + def execute + variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h + query = params[:query] + operation_name = params[:operationName] + context = { + current_user: current_user + } + result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + render json: result + end + + rescue_from StandardError do |exception| + log_exception(exception) + + render_error("Internal server error") + end + + rescue_from Gitlab::Graphql::Variables::Invalid do |exception| + render_error(exception.message, status: :unprocessable_entity) + end + + private + + # Overridden from the ApplicationController to make the response look like + # a GraphQL response. That is nicely picked up in Graphiql. + def render_404 + render_error("Not found!", status: :not_found) + end + + def render_error(message, status: 500) + error = { errors: [message: message] } + + render json: error, status: status + end + + def check_graphql_feature_flag! + render_404 unless Feature.enabled?(:graphql) + end +end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index ef3eba80154..ef5d5e5c742 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -3,8 +3,12 @@ class Groups::GroupMembersController < Groups::ApplicationController include MembersPresentation include SortingHelper + def self.admin_not_required_endpoints + %i[index leave request_access] + end + # Authorize - before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] + before_action :authorize_admin_group_member!, except: admin_not_required_endpoints skip_cross_project_access_check :index, :create, :update, :destroy, :request_access, :approve_access_request, :leave, :resend_invite, diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 58be330f466..863f50e8e66 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -2,6 +2,7 @@ class Groups::LabelsController < Groups::ApplicationController include ToggleSubscriptionAction before_action :label, only: [:edit, :update, :destroy] + before_action :available_labels, only: [:index] before_action :authorize_admin_labels!, only: [:new, :create, :edit, :update, :destroy] before_action :save_previous_label_path, only: [:edit] @@ -12,17 +13,8 @@ class Groups::LabelsController < Groups::ApplicationController format.html do @labels = @group.labels.page(params[:page]) end - format.json do - available_labels = LabelsFinder.new( - current_user, - group_id: @group.id, - only_group_labels: params[:only_group_labels], - include_ancestor_groups: params[:include_ancestor_groups], - include_descendant_groups: params[:include_descendant_groups] - ).execute - - render json: LabelSerializer.new.represent_appearance(available_labels) + render json: LabelSerializer.new.represent_appearance(@available_labels) end end end @@ -113,4 +105,15 @@ class Groups::LabelsController < Groups::ApplicationController def save_previous_label_path session[:previous_labels_path] = URI(request.referer || '').path end + + def available_labels + @available_labels ||= + LabelsFinder.new( + current_user, + group_id: @group.id, + only_group_labels: params[:only_group_labels], + include_ancestor_groups: params[:include_ancestor_groups], + include_descendant_groups: params[:include_descendant_groups] + ).execute + end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 5903689dc62..9bd51de7e97 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -76,12 +76,15 @@ class Groups::MilestonesController < Groups::ApplicationController def milestones milestones = MilestonesFinder.new(search_params).execute - legacy_milestones = GroupMilestone.build_collection(group, group_projects, params) @sort = params[:sort] || 'due_date_asc' MilestoneArray.sort(milestones + legacy_milestones, @sort) end + def legacy_milestones + GroupMilestone.build_collection(group, group_projects, params) + end + def milestone @milestone = if params[:title] diff --git a/app/controllers/groups/shared_projects_controller.rb b/app/controllers/groups/shared_projects_controller.rb index f2f835767e0..7dec1f5f402 100644 --- a/app/controllers/groups/shared_projects_controller.rb +++ b/app/controllers/groups/shared_projects_controller.rb @@ -24,7 +24,9 @@ module Groups # Make the `search` param consistent for the frontend, # which will be using `filter`. params[:search] ||= params[:filter] if params[:filter] - params.permit(:sort, :search) + # Don't show archived projects + params[:non_archived] = true + params.permit(:sort, :search, :non_archived) end end end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 16abf7bab7e..3fedd5bfb29 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,5 +1,5 @@ class HealthController < ActionController::Base - protect_from_forgery with: :exception, except: :storage_check + protect_from_forgery with: :exception, except: :storage_check, prepend: true include RequiresWhitelistedMonitoringClient CHECKS = [ @@ -8,7 +8,6 @@ class HealthController < ActionController::Base Gitlab::HealthChecks::Redis::CacheCheck, Gitlab::HealthChecks::Redis::QueuesCheck, Gitlab::HealthChecks::Redis::SharedStateCheck, - Gitlab::HealthChecks::FsShardsCheck, Gitlab::HealthChecks::GitalyCheck ].freeze diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index 663269a0f92..5766c6924cd 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -25,4 +25,8 @@ class Import::BaseController < ApplicationController current_user.namespace end + + def project_save_error(project) + project.errors.full_messages.join(', ') + end end diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 77af5fb9c4f..fa31933e778 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -55,7 +55,7 @@ class Import::BitbucketController < Import::BaseController if project.persisted? render json: ProjectSerializer.new.represent(project) else - render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + render json: { errors: project_save_error(project) }, status: :unprocessable_entity end else render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 25ec13b8075..2d665e05ac3 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -66,7 +66,7 @@ class Import::FogbugzController < Import::BaseController if project.persisted? render json: ProjectSerializer.new.represent(project) else - render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + render json: { errors: project_save_error(project) }, status: :unprocessable_entity end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index f67ec4c248b..c9870332c0f 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -48,7 +48,7 @@ class Import::GithubController < Import::BaseController if project.persisted? render json: ProjectSerializer.new.represent(project) else - render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + render json: { errors: project_save_error(project) }, status: :unprocessable_entity end else render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index 39e2e9e094b..fccbdbca0f6 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -32,7 +32,7 @@ class Import::GitlabController < Import::BaseController if project.persisted? render json: ProjectSerializer.new.represent(project) else - render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + render json: { errors: project_save_error(project) }, status: :unprocessable_entity end else render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb index 9b26a00f7c7..3bce27e810a 100644 --- a/app/controllers/import/google_code_controller.rb +++ b/app/controllers/import/google_code_controller.rb @@ -92,7 +92,7 @@ class Import::GoogleCodeController < Import::BaseController if project.persisted? render json: ProjectSerializer.new.represent(project) else - render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + render json: { errors: project_save_error(project) }, status: :unprocessable_entity end end diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 33b682d2859..0400ffcfee5 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -1,7 +1,7 @@ class MetricsController < ActionController::Base include RequiresWhitelistedMonitoringClient - protect_from_forgery with: :exception + protect_from_forgery with: :exception, prepend: true def index response = if Gitlab::Metrics.prometheus_metrics_enabled? diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 27fd5f7ba37..1547d4b5972 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -2,7 +2,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable - protect_from_forgery except: [:kerberos, :saml, :cas3] + protect_from_forgery except: [:kerberos, :saml, :cas3], prepend: true def handle_omniauth omniauth_flow(Gitlab::Auth::OAuth) @@ -119,7 +119,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController set_remember_me(user) - if user.two_factor_enabled? + if user.two_factor_enabled? && !auth_user.bypass_two_factor? prompt_for_two_factor(user) else sign_in_and_redirect(user) diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index abc283d7aa9..6484a713f8e 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -7,6 +7,7 @@ class Projects::ArtifactsController < Projects::ApplicationController before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path + before_action :set_request_format, only: [:file] before_action :validate_artifacts! before_action :entry, only: [:file] @@ -101,4 +102,12 @@ class Projects::ArtifactsController < Projects::ApplicationController render_404 unless @entry.exists? end + + def set_request_format + request.format = :html if set_request_format? + end + + def set_request_format? + request.format != :json + end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 0c1c286a0a4..ebc61264b39 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -3,10 +3,11 @@ class Projects::BlobController < Projects::ApplicationController include ExtractsPath include CreatesCommit include RendersBlob + include NotesHelper include ActionView::Helpers::SanitizeHelper - prepend_before_action :authenticate_user!, only: [:edit] + before_action :set_request_format, only: [:edit, :show, :update] before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! @@ -92,6 +93,7 @@ class Projects::BlobController < Projects::ApplicationController @lines = Gitlab::Highlight.highlight(@blob.path, @blob.data, repository: @repository).lines @form = UnfoldForm.new(params) + @lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe) if @form.bottom? @@ -102,11 +104,50 @@ class Projects::BlobController < Projects::ApplicationController @match_line = "@@ -#{line}+#{line} @@" end - render layout: false + # We can keep only 'render_diff_lines' from this conditional when + # https://gitlab.com/gitlab-org/gitlab-ce/issues/44988 is done + if rendered_for_merge_request? + render_diff_lines + else + render layout: false + end end private + # Converts a String array to Gitlab::Diff::Line array + def render_diff_lines + @lines.map! do |line| + # These are marked as context lines but are loaded from blobs. + # We also have context lines loaded from diffs in other places. + diff_line = Gitlab::Diff::Line.new(line, 'context', nil, nil, nil) + diff_line.rich_text = line + diff_line + end + + add_match_line + + render json: @lines + end + + def add_match_line + return unless @form.unfold? + + if @form.bottom? && @form.to < @blob.lines.size + old_pos = @form.to - @form.offset + new_pos = @form.to + elsif @form.since != 1 + old_pos = new_pos = @form.since + end + + # Match line is not needed when it reaches the top limit or bottom limit of the file. + return unless new_pos + + @match_line = Gitlab::Diff::Line.new(@match_line, 'match', nil, old_pos, new_pos) + + @form.bottom? ? @lines.push(@match_line) : @lines.unshift(@match_line) + end + def blob @blob ||= @repository.blob_at(@commit.id, @path) @@ -188,6 +229,18 @@ class Projects::BlobController < Projects::ApplicationController .last_for_path(@repository, @ref, @path).sha end + # In Rails 4.2 if params[:format] is empty, Rails set it to :html + # But since Rails 5.0 the framework now looks for an extension. + # E.g. for `blob/master/CHANGELOG.md` in Rails 4 the format would be `:html`, but in Rails 5 on it'd be `:md` + # This before_action explicitly sets the `:html` format for all requests unless `:format` is set by a client e.g. by JS for XHR requests. + def set_request_format + request.format = :html if set_request_format? + end + + def set_request_format? + params[:id].present? && params[:format].blank? && request.format != "json" + end + def show_html environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @@ -197,15 +250,14 @@ class Projects::BlobController < Projects::ApplicationController end def show_json - json = blob_json(@blob) - return render_404 unless json - + set_last_commit_sha path_segments = @path.split('/') path_segments.pop tree_path = path_segments.join('/') - render json: json.merge( + json = { id: @blob.id, + last_commit_sha: @last_commit_sha, path: blob.path, name: blob.name, extension: blob.extension, @@ -221,6 +273,10 @@ class Projects::BlobController < Projects::ApplicationController commits_path: project_commits_path(project, @id), tree_path: project_tree_path(project, File.join(@ref, tree_path)), permalink: project_blob_path(project, File.join(@commit.id, @path)) - ) + } + + json.merge!(blob_json(@blob) || {}) unless params[:viewer] == 'none' + + render json: json end end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index b7b36f770f5..cd7250b10fc 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -31,7 +31,10 @@ class Projects::BranchesController < Projects::ApplicationController end end - render + # https://gitlab.com/gitlab-org/gitlab-ce/issues/48097 + Gitlab::GitalyClient.allow_n_plus_1_calls do + render + end end format.json do branches = BranchesFinder.new(@repository, params).execute diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb index 4d758402850..a5c82caa897 100644 --- a/app/controllers/projects/clusters/applications_controller.rb +++ b/app/controllers/projects/clusters/applications_controller.rb @@ -42,6 +42,6 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll owner: current_user } - Applications::CreateService.new(current_user, oauth_application_params).execute + Applications::CreateService.new(current_user, oauth_application_params).execute(request) end end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 7b7cb52d7ed..9e495061f4e 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -9,6 +9,7 @@ class Projects::CommitsController < Projects::ApplicationController before_action :assign_ref_vars before_action :authorize_download_code! before_action :set_commits + before_action :set_request_format, only: :show def show @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened @@ -61,6 +62,19 @@ class Projects::CommitsController < Projects::ApplicationController @commits = prepare_commits_for_rendering(@commits) end + # Rails 5 sets request.format from the extension. + # Explicitly set to :html. + def set_request_format + request.format = :html if set_request_format? + end + + # Rails 5 sets request.format from extension. + # In this case if the ref ends with `.atom`, it's expected to be the html response, + # not the atom one. So explicitly set request.format as :html to act like rails4. + def set_request_format? + request.format.to_s == "text/html" || @commits.ref.ends_with?("atom") + end + def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42330') end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 8e86af43fee..78b9d53a780 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -21,7 +21,7 @@ class Projects::DiscussionsController < Projects::ApplicationController def show render json: { - discussion_html: view_to_html_string('discussions/_diff_with_notes', discussion: discussion, expanded: true) + truncated_diff_lines: discussion.try(:truncated_diff_lines) } end @@ -29,11 +29,6 @@ class Projects::DiscussionsController < Projects::ApplicationController def render_discussion if serialize_notes? - # TODO - It is not needed to serialize notes when resolving - # or unresolving discussions. We should remove this behavior - # passing a parameter to DiscussionEntity to return an empty array - # for notes. - # Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853 prepare_notes_for_rendering(discussion.notes, merge_request) render_json_with_discussions_serializer else @@ -44,7 +39,7 @@ class Projects::DiscussionsController < Projects::ApplicationController def render_json_with_discussions_serializer render json: DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity) - .represent(discussion, context: self) + .represent(discussion, context: self, render_truncated_diff_lines: true) end # Legacy method used to render discussions notes when not using Vue on views. diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 35c36c725e2..7c897b2d86c 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -4,6 +4,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableActions include ToggleAwardEmoji include IssuableCollections + include IssuesCalendar include SpammableActions prepend_before_action :authenticate_user!, only: [:new] @@ -40,14 +41,7 @@ class Projects::IssuesController < Projects::ApplicationController end def calendar - @issues = @issuables - .non_archived - .with_due_date - .limit(100) - - respond_to do |format| - format.ics { response.headers['Content-Disposition'] = 'inline' } - end + render_issues_calendar(@issuables) end def new diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index dd12d30a085..63f0aea3195 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -160,7 +160,7 @@ class Projects::JobsController < Projects::ApplicationController def build @build ||= project.builds.find(params[:id]) - .present(current_user: current_user) + .present(current_user: current_user) end def build_path(build) diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index ee4ed674110..3f4962b543d 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -93,7 +93,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController end def lfs_check_batch_operation! - if upload_request? && Gitlab::Database.read_only? + if batch_operation_disallowed? render( json: { message: lfs_read_only_message @@ -105,6 +105,11 @@ class Projects::LfsApiController < Projects::GitHttpClientController end # Overridden in EE + def batch_operation_disallowed? + upload_request? && Gitlab::Database.read_only? + end + + # Overridden in EE def lfs_read_only_message _('You cannot write to this read-only GitLab instance.') end diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb index 43d8867a536..45c98d60822 100644 --- a/app/controllers/projects/lfs_storage_controller.rb +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -18,7 +18,7 @@ class Projects::LfsStorageController < Projects::GitHttpClientController def upload_authorize set_workhorse_internal_api_content_type - authorized = LfsObjectUploader.workhorse_authorize + authorized = LfsObjectUploader.workhorse_authorize(has_length: true) authorized.merge!(LfsOid: oid, LfsSize: size) render json: authorized diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 29632bef7e5..8e4aeec16dc 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -15,7 +15,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont def merge_request_params_attributes [ - :allow_maintainer_to_push, + :allow_collaboration, :assignee_id, :description, :force_remove_source_branch, diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index fe8525a488c..48e02581d54 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -9,17 +9,21 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic before_action :define_diff_comment_vars def show - @environment = @merge_request.environments_for(current_user).last - - render json: { html: view_to_html_string("projects/merge_requests/diffs/_diffs") } + render_diffs end def diff_for_path - render_diff_for_path(@diffs) + render_diffs end private + def render_diffs + @environment = @merge_request.environments_for(current_user).last + + render json: DiffsSerializer.new(current_user: current_user).represent(@diffs, additional_attributes) + end + def define_diff_vars @merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc @compare = commit || find_merge_request_diff_compare @@ -63,6 +67,19 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end end + def additional_attributes + { + environment: @environment, + merge_request: @merge_request, + merge_request_diff: @merge_request_diff, + merge_request_diffs: @merge_request_diffs, + start_version: @start_version, + start_sha: @start_sha, + commit: @commit, + latest_diff: @merge_request_diff&.latest? + } + end + def define_diff_comment_vars @new_diff_note_attrs = { noteable_type: 'MergeRequest', diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index ecea6e1b2bf..a7c5f858c42 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -28,21 +28,23 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def show - validates_merge_request - close_merge_request_without_source_project - check_if_can_be_merged - - # Return if the response has already been rendered - return if response_body + close_merge_request_if_no_source_project + mark_merge_request_mergeable respond_to do |format| format.html do + # use next to appease Rubocop + next render('invalid') if target_branch_missing? + # Build a note object for comment form @note = @project.notes.new(noteable: @merge_request) @noteable = @merge_request @commits_count = @merge_request.commits_count + # TODO cleanup- Fatih Simon Create an issue to remove these after the refactoring + # we no longer render notes here. I see it will require a small frontend refactoring, + # since we gather some data from this collection. @discussions = @merge_request.discussions @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable) @@ -116,7 +118,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end format.json do - render json: @merge_request.to_json(include: { milestone: {}, assignee: { only: [:name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }, methods: [:task_status, :task_status_short]) + render json: serializer.represent(@merge_request, serializer: 'basic') end end rescue ActiveRecord::StaleObjectError @@ -234,20 +236,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo alias_method :issuable, :merge_request alias_method :awardable, :merge_request - def validates_merge_request - # Show git not found page - # if there is no saved commits between source & target branch - if @merge_request.has_no_commits? - # and if target branch doesn't exist - return invalid_mr unless @merge_request.target_branch_exists? - end - end - - def invalid_mr - # Render special view for MR with removed target branch - render 'invalid' - end - def merge_params params.permit(merge_params_attributes) end @@ -261,7 +249,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.head_pipeline && @merge_request.head_pipeline.active? end - def close_merge_request_without_source_project + def close_merge_request_if_no_source_project if !@merge_request.source_project && @merge_request.open? @merge_request.close end @@ -269,7 +257,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo private - def check_if_can_be_merged + def target_branch_missing? + @merge_request.has_no_commits? && !@merge_request.target_branch_exists? + end + + def mark_merge_request_mergeable @merge_request.check_if_can_be_merged end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index c5a044541f1..594563d1f6f 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -1,4 +1,5 @@ class Projects::MilestonesController < Projects::ApplicationController + include Gitlab::Utils::StrongMemoize include MilestoneActions before_action :check_issuables_available! @@ -76,7 +77,7 @@ class Projects::MilestonesController < Projects::ApplicationController def promote promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = "#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, promoted_milestone.iid)}\">group milestone</a>.".html_safe + flash[:notice] = "#{milestone.title} promoted to <a href=\"#{group_milestone_path(project.group, promoted_milestone.iid)}\"><u>group milestone</u></a>.".html_safe respond_to do |format| format.html do redirect_to project_milestones_path(project) @@ -103,7 +104,7 @@ class Projects::MilestonesController < Projects::ApplicationController protected def milestones - @milestones ||= begin + strong_memoize(:milestones) do MilestonesFinder.new(search_params).execute end end @@ -121,10 +122,10 @@ class Projects::MilestonesController < Projects::ApplicationController end def search_params - if @project.group && can?(current_user, :read_group, @project.group) - group = @project.group + if request.format.json? && @project.group && can?(current_user, :read_group, @project.group) + groups = @project.group.self_and_ancestors_ids end - params.permit(:state).merge(project_ids: @project.id, group_ids: group&.id) + params.permit(:state).merge(project_ids: @project.id, group_ids: groups) end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 6b40fc2fe68..768595ceeb4 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -23,8 +23,6 @@ class Projects::PipelinesController < Projects::ApplicationController @finished_count = limited_pipelines_count(project, 'finished') @pipelines_count = limited_pipelines_count(project) - Gitlab::Ci::Pipeline::Preloader.preload(@pipelines) - respond_to do |format| format.html format.json do @@ -34,7 +32,7 @@ class Projects::PipelinesController < Projects::ApplicationController pipelines: PipelineSerializer .new(project: @project, current_user: @current_user) .with_pagination(request, response) - .represent(@pipelines, disable_coverage: true), + .represent(@pipelines, disable_coverage: true, preload: true), count: { all: @pipelines_count, running: @running_count, diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 1d850baf012..fb3f6eec2bd 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -41,7 +41,7 @@ module Projects :runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_human_readable, :build_coverage_regex, :public_builds, :auto_cancel_pending_pipelines, :ci_config_path, - auto_devops_attributes: [:id, :domain, :enabled] + auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy] ) end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 242e6491456..aa844e94d89 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -95,6 +95,7 @@ class Projects::WikisController < Projects::ApplicationController def destroy @page = @project_wiki.find_page(params[:id]) + WikiPages::DestroyService.new(@project, current_user).execute(@page) redirect_to project_wiki_path(@project, :home), diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a93b116c6fe..c2492a137fb 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -63,7 +63,7 @@ class ProjectsController < Projects::ApplicationController redirect_to(edit_project_path(@project)) end else - flash[:alert] = result[:message] + flash.now[:alert] = result[:message] format.html { render 'edit' } end @@ -247,13 +247,13 @@ class ProjectsController < Projects::ApplicationController if find_branches branches = BranchesFinder.new(@repository, params).execute.take(100).map(&:name) - options[s_('RefSwitcher|Branches')] = branches + options['Branches'] = branches end if find_tags && @repository.tag_count.nonzero? tags = TagsFinder.new(@repository, params).execute.take(100).map(&:name) - options[s_('RefSwitcher|Tags')] = tags + options['Tags'] = tags end # If reference is commit id - we should add it to branch/tag selectbox diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index f5a222b3a48..e6d6965036e 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -3,6 +3,9 @@ class RegistrationsController < Devise::RegistrationsController include AcceptsPendingInvitations before_action :whitelist_query_limiting, only: [:destroy] + before_action :ensure_terms_accepted, + if: -> { Gitlab::CurrentSettings.current_application_settings.enforce_terms? }, + only: [:create] def new redirect_to(new_user_session_path) @@ -18,7 +21,9 @@ class RegistrationsController < Devise::RegistrationsController if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha accept_pending_invitations - super + super do |new_user| + persist_accepted_terms_if_required(new_user) + end else flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' flash.delete :recaptcha_error @@ -40,6 +45,16 @@ class RegistrationsController < Devise::RegistrationsController protected + def persist_accepted_terms_if_required(new_user) + return unless new_user.persisted? + return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms? + + if terms_accepted? + terms = ApplicationSetting::Term.latest + Users::RespondToTermsService.new(new_user, terms).execute(accepted: true) + end + end + def destroy_confirmation_valid? if current_user.confirm_deletion_with_password? current_user.valid_password?(params[:password]) @@ -91,4 +106,14 @@ class RegistrationsController < Devise::RegistrationsController def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42380') end + + def ensure_terms_accepted + return if terms_accepted? + + redirect_to new_user_session_path, alert: _('You must accept our Terms of Service and privacy policy in order to register an account') + end + + def terms_accepted? + Gitlab::Utils.to_boolean(params[:terms_opt_in]) + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1a339f76d26..7aa277b3614 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -3,21 +3,27 @@ class SessionsController < Devise::SessionsController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable include Recaptcha::ClientHelper + include Recaptcha::Verify skip_before_action :check_two_factor_requirement, only: [:destroy] prepend_before_action :check_initial_setup, only: [:new] prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + prepend_before_action :check_captcha, only: [:create] prepend_before_action :store_redirect_uri, only: [:new] + prepend_before_action :ldap_servers, only: [:new, :create] before_action :auto_sign_in_with_provider, only: [:new] before_action :load_recaptcha after_action :log_failed_login, only: [:new], if: :failed_login? + helper_method :captcha_enabled? + + CAPTCHA_HEADER = 'X-GitLab-Show-Login-Captcha'.freeze + def new set_minimum_password_length - @ldap_servers = Gitlab::Auth::LDAP::Config.available_servers super end @@ -46,6 +52,25 @@ class SessionsController < Devise::SessionsController private + def captcha_enabled? + request.headers[CAPTCHA_HEADER] && Gitlab::Recaptcha.enabled? + end + + # From https://github.com/plataformatec/devise/wiki/How-To:-Use-Recaptcha-with-Devise#devisepasswordscontroller + def check_captcha + return unless user_params[:password].present? + return unless captcha_enabled? + return unless Gitlab::Recaptcha.load_configurations! + + unless verify_recaptcha + self.resource = resource_class.new + flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' + flash.delete :recaptcha_error + + respond_with_navigational(resource) { render :new } + end + end + def log_failed_login Gitlab::AppLogger.info("Failed Login: username=#{user_params[:login]} ip=#{request.remote_ip}") end @@ -152,6 +177,10 @@ class SessionsController < Devise::SessionsController Gitlab::Recaptcha.load_configurations! end + def ldap_servers + @ldap_servers ||= Gitlab::Auth::LDAP::Config.available_servers + end + def authentication_method if user_params[:otp_attempt] "two-factor" diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb index ab685b9106e..1b1560a2a00 100644 --- a/app/controllers/users/terms_controller.rb +++ b/app/controllers/users/terms_controller.rb @@ -2,6 +2,7 @@ module Users class TermsController < ApplicationController include InternalRedirect + skip_before_action :authenticate_user! skip_before_action :enforce_terms! skip_before_action :check_password_expiration skip_before_action :check_two_factor_requirement @@ -13,6 +14,10 @@ module Users def index @redirect = redirect_path + + if current_user && @term.accepted_by_user?(current_user) + flash.now[:notice] = "You have already accepted the Terms of Service as #{current_user.to_reference}" + end end def accept diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 067aff408df..2a656c0d31c 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -3,17 +3,29 @@ class GroupMembersFinder @group = group end - def execute + def execute(include_descendants: false) group_members = @group.members + wheres = [] - return group_members unless @group.parent + return group_members unless @group.parent || include_descendants - parents_members = GroupMember.non_request - .where(source_id: @group.ancestors.select(:id)) - .where.not(user_id: @group.users.select(:id)) + wheres << "members.id IN (#{group_members.select(:id).to_sql})" - wheres = ["members.id IN (#{group_members.select(:id).to_sql})"] - wheres << "members.id IN (#{parents_members.select(:id).to_sql})" + if @group.parent + parents_members = GroupMember.non_request + .where(source_id: @group.ancestors.select(:id)) + .where.not(user_id: @group.users.select(:id)) + + wheres << "members.id IN (#{parents_members.select(:id).to_sql})" + end + + if include_descendants + descendant_members = GroupMember.non_request + .where(source_id: @group.descendants.select(:id)) + .where.not(user_id: @group.users.select(:id)) + + wheres << "members.id IN (#{descendant_members.select(:id).to_sql})" + end GroupMember.where(wheres.join(' OR ')) end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index c6ef79ce15e..6fdfd964fca 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -7,7 +7,7 @@ # current_user - which user use # params: # scope: 'created_by_me' or 'assigned_to_me' or 'all' -# state: 'opened' or 'closed' or 'all' +# state: 'opened' or 'closed' or 'locked' or 'all' # group_id: integer # project_id: integer # milestone_title: string @@ -23,6 +23,7 @@ # created_before: datetime # updated_after: datetime # updated_before: datetime +# use_cte_for_search: boolean # class IssuableFinder prepend FinderWithCrossProjectAccess @@ -54,6 +55,7 @@ class IssuableFinder sort state include_subgroups + use_cte_for_search ] end @@ -74,19 +76,21 @@ class IssuableFinder items = init_collection items = filter_items(items) - # Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far - items = by_project(items) + # This has to be last as we may use a CTE as an optimization fence by + # passing the use_cte_for_search param + # https://www.postgresql.org/docs/current/static/queries-with.html + items = by_search(items) sort(items) end def filter_items(items) + items = by_project(items) items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) items = by_state(items) items = by_group(items) - items = by_search(items) items = by_assignee(items) items = by_author(items) items = by_non_archived(items) @@ -107,7 +111,6 @@ class IssuableFinder # def count_by_state count_params = params.merge(state: nil, sort: nil) - labels_count = label_names.any? ? label_names.count : 1 finder = self.class.new(current_user, count_params) counts = Hash.new(0) @@ -116,6 +119,11 @@ class IssuableFinder # per issuable, so we have to count those in Ruby - which is bad, but still # better than performing multiple queries. # + # This does not apply when we are using a CTE for the search, as the labels + # GROUP BY is inside the subquery in that case, so we set labels_count to 1. + labels_count = label_names.any? ? label_names.count : 1 + labels_count = 1 if use_cte_for_search? + finder.execute.reorder(nil).group(:state).count.each do |key, value| counts[Array(key).last.to_sym] += value / labels_count end @@ -159,10 +167,7 @@ class IssuableFinder finder_options = { include_subgroups: params[:include_subgroups], only_owned: true } GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute else - opts = { current_user: current_user } - opts[:project_ids_relation] = item_project_ids(items) if items - - ProjectsFinder.new(opts).execute + ProjectsFinder.new(current_user: current_user).execute end @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) @@ -306,6 +311,8 @@ class IssuableFinder items.respond_to?(:merged) ? items.merged : items.closed when 'opened' items.opened + when 'locked' + items.where(state: 'locked') else items end @@ -329,8 +336,24 @@ class IssuableFinder items end + def use_cte_for_search? + return false unless search + return false unless Gitlab::Database.postgresql? + + params[:use_cte_for_search] + end + def by_search(items) - search ? items.full_search(search) : items + return items unless search + + if use_cte_for_search? + cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name) + cte << items + + items = klass.with(cte.to_arel).from(klass.table_name) + end + + items.full_search(search) end def by_iids(items) diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 3626670d141..24a6b9349a0 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -136,8 +136,4 @@ class IssuesFinder < IssuableFinder items end end - - def item_project_ids(items) - items&.reorder(nil)&.select(:project_id) - end end diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 4734d97b8c7..4c893ae2de6 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -7,12 +7,12 @@ class MembersFinder @group = project.group end - def execute + def execute(include_descendants: false) project_members = project.project_members project_members = project_members.non_invite unless can?(current_user, :admin_project, project) if group - group_members = GroupMembersFinder.new(group).execute + group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants) group_members = group_members.non_invite union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index e2240e5e0d8..40089c082c1 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -6,7 +6,7 @@ # current_user - which user use # params: # scope: 'created_by_me' or 'assigned_to_me' or 'all' -# state: 'open', 'closed', 'merged', or 'all' +# state: 'open', 'closed', 'merged', 'locked', or 'all' # group_id: integer # project_id: integer # milestone_title: string @@ -56,8 +56,4 @@ class MergeRequestsFinder < IssuableFinder items.where(target_branch: target_branch) end - - def item_project_ids(items) - items&.reorder(nil)&.select(:target_project_id) - end end diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 35f4ff2f62f..9b7a35fb3b5 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -83,7 +83,7 @@ class NotesFinder when "personal_snippet" PersonalSnippet.all else - raise 'invalid target_type' + raise "invalid target_type '#{noteable_type}'" end end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index d498a2d6d11..9d3772d7541 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -54,7 +54,10 @@ class SnippetsFinder < UnionFinder end def authorized_snippets - Snippet.where(feature_available_projects.or(not_project_related)) + # This query was intentionally converted to a raw one to get it work in Rails 5.0. + # In Rails 5.0 and 5.1 there's a bug: https://github.com/rails/arel/issues/531 + # Please convert it back when on rails 5.2 as it works again as expected since 5.2. + Snippet.where("#{feature_available_projects} OR #{not_project_related}") .public_or_visible_to_user(current_user) end @@ -86,18 +89,20 @@ class SnippetsFinder < UnionFinder def feature_available_projects # Don't return any project related snippets if the user cannot read cross project - return table[:id].eq(nil) unless Ability.allowed?(current_user, :read_cross_project) + return table[:id].eq(nil).to_sql unless Ability.allowed?(current_user, :read_cross_project) projects = projects_for_user do |part| part.with_feature_available_for_user(:snippets, current_user) end.select(:id) - arel_query = Arel::Nodes::SqlLiteral.new(projects.to_sql) - table[:project_id].in(arel_query) + # This query was intentionally converted to a raw one to get it work in Rails 5.0. + # In Rails 5.0 and 5.1 there's a bug: https://github.com/rails/arel/issues/531 + # Please convert it back when on rails 5.2 as it works again as expected since 5.2. + "snippets.project_id IN (#{projects.to_sql})" end def not_project_related - table[:project_id].eq(nil) + table[:project_id].eq(nil).to_sql end def table diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb index 65d6e019746..74776b2ed1f 100644 --- a/app/finders/user_recent_events_finder.rb +++ b/app/finders/user_recent_events_finder.rb @@ -56,7 +56,7 @@ class UserRecentEventsFinder visible = target_user .project_interactions - .where(visibility_level: [Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::PUBLIC]) + .where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(current_user)) .select(:id) Gitlab::SQL::Union.new([authorized, visible]).to_sql diff --git a/app/graphql/functions/base_function.rb b/app/graphql/functions/base_function.rb new file mode 100644 index 00000000000..42fb8f99acc --- /dev/null +++ b/app/graphql/functions/base_function.rb @@ -0,0 +1,4 @@ +module Functions + class BaseFunction < GraphQL::Function + end +end diff --git a/app/graphql/functions/echo.rb b/app/graphql/functions/echo.rb new file mode 100644 index 00000000000..e5bf109b8d7 --- /dev/null +++ b/app/graphql/functions/echo.rb @@ -0,0 +1,13 @@ +module Functions + class Echo < BaseFunction + argument :text, GraphQL::STRING_TYPE + + description "Testing endpoint to validate the API with" + + def call(obj, args, ctx) + username = ctx[:current_user]&.username + + "#{username.inspect} says: #{args[:text]}" + end + end +end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb new file mode 100644 index 00000000000..de4fc1d8e32 --- /dev/null +++ b/app/graphql/gitlab_schema.rb @@ -0,0 +1,8 @@ +class GitlabSchema < GraphQL::Schema + use BatchLoader::GraphQL + use Gitlab::Graphql::Authorize + use Gitlab::Graphql::Present + + query(Types::QueryType) + # mutation(Types::MutationType) +end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/app/graphql/mutations/.keep diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb new file mode 100644 index 00000000000..89b7f9dad6f --- /dev/null +++ b/app/graphql/resolvers/base_resolver.rb @@ -0,0 +1,4 @@ +module Resolvers + class BaseResolver < GraphQL::Schema::Resolver + end +end diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb new file mode 100644 index 00000000000..4eb28aaed6c --- /dev/null +++ b/app/graphql/resolvers/full_path_resolver.rb @@ -0,0 +1,19 @@ +module Resolvers + module FullPathResolver + extend ActiveSupport::Concern + + prepended do + argument :full_path, GraphQL::ID_TYPE, + required: true, + description: 'The full path of the project or namespace, e.g., "gitlab-org/gitlab-ce"' + end + + def model_by_full_path(model, full_path) + BatchLoader.for(full_path).batch(key: "#{model.model_name.param_key}:full_path") do |full_paths, loader| + # `with_route` avoids an N+1 calculating full_path + results = model.where_full_path_in(full_paths).with_route + results.each { |project| loader.call(project.full_path, project) } + end + end + end +end diff --git a/app/graphql/resolvers/merge_request_resolver.rb b/app/graphql/resolvers/merge_request_resolver.rb new file mode 100644 index 00000000000..9f2d348e95f --- /dev/null +++ b/app/graphql/resolvers/merge_request_resolver.rb @@ -0,0 +1,20 @@ +module Resolvers + class MergeRequestResolver < BaseResolver + argument :iid, GraphQL::ID_TYPE, + required: true, + description: 'The IID of the merge request, e.g., "1"' + + type Types::MergeRequestType, null: true + + alias_method :project, :object + + def resolve(iid:) + return unless project.present? + + BatchLoader.for(iid.to_s).batch(key: project.id) do |iids, loader| + results = project.merge_requests.where(iid: iids) + results.each { |mr| loader.call(mr.iid.to_s, mr) } + end + end + end +end diff --git a/app/graphql/resolvers/project_resolver.rb b/app/graphql/resolvers/project_resolver.rb new file mode 100644 index 00000000000..ec115bad896 --- /dev/null +++ b/app/graphql/resolvers/project_resolver.rb @@ -0,0 +1,11 @@ +module Resolvers + class ProjectResolver < BaseResolver + prepend FullPathResolver + + type Types::ProjectType, null: true + + def resolve(full_path:) + model_by_full_path(Project, full_path) + end + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb new file mode 100644 index 00000000000..b45a845f74f --- /dev/null +++ b/app/graphql/types/base_enum.rb @@ -0,0 +1,4 @@ +module Types + class BaseEnum < GraphQL::Schema::Enum + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb new file mode 100644 index 00000000000..c5740a334d7 --- /dev/null +++ b/app/graphql/types/base_field.rb @@ -0,0 +1,5 @@ +module Types + class BaseField < GraphQL::Schema::Field + prepend Gitlab::Graphql::Authorize + end +end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb new file mode 100644 index 00000000000..309e336e6c8 --- /dev/null +++ b/app/graphql/types/base_input_object.rb @@ -0,0 +1,4 @@ +module Types + class BaseInputObject < GraphQL::Schema::InputObject + end +end diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb new file mode 100644 index 00000000000..69e72dc5808 --- /dev/null +++ b/app/graphql/types/base_interface.rb @@ -0,0 +1,5 @@ +module Types + module BaseInterface + include GraphQL::Schema::Interface + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb new file mode 100644 index 00000000000..754adf4c04d --- /dev/null +++ b/app/graphql/types/base_object.rb @@ -0,0 +1,8 @@ +module Types + class BaseObject < GraphQL::Schema::Object + prepend Gitlab::Graphql::Present + prepend Gitlab::Graphql::ExposePermissions + + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb new file mode 100644 index 00000000000..c0aa38be239 --- /dev/null +++ b/app/graphql/types/base_scalar.rb @@ -0,0 +1,4 @@ +module Types + class BaseScalar < GraphQL::Schema::Scalar + end +end diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb new file mode 100644 index 00000000000..36337fc6ee5 --- /dev/null +++ b/app/graphql/types/base_union.rb @@ -0,0 +1,4 @@ +module Types + class BaseUnion < GraphQL::Schema::Union + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb new file mode 100644 index 00000000000..a1f3c0dd8c0 --- /dev/null +++ b/app/graphql/types/merge_request_type.rb @@ -0,0 +1,49 @@ +module Types + class MergeRequestType < BaseObject + expose_permissions Types::PermissionTypes::MergeRequest + + present_using MergeRequestPresenter + + graphql_name 'MergeRequest' + + field :id, GraphQL::ID_TYPE, null: false + field :iid, GraphQL::ID_TYPE, null: false + field :title, GraphQL::STRING_TYPE, null: false + field :description, GraphQL::STRING_TYPE, null: true + field :state, GraphQL::STRING_TYPE, null: true + field :created_at, Types::TimeType, null: false + field :updated_at, Types::TimeType, null: false + field :source_project, Types::ProjectType, null: true + field :target_project, Types::ProjectType, null: false + # Alias for target_project + field :project, Types::ProjectType, null: false + field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id + field :source_project_id, GraphQL::INT_TYPE, null: true + field :target_project_id, GraphQL::INT_TYPE, null: false + field :source_branch, GraphQL::STRING_TYPE, null: false + field :target_branch, GraphQL::STRING_TYPE, null: false + field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false + field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true + field :diff_head_sha, GraphQL::STRING_TYPE, null: true + field :merge_commit_sha, GraphQL::STRING_TYPE, null: true + field :user_notes_count, GraphQL::INT_TYPE, null: true + field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true + field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true + field :merge_status, GraphQL::STRING_TYPE, null: true + field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true + field :merge_error, GraphQL::STRING_TYPE, null: true + field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true + field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false + field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true + field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false + field :diff_head_sha, GraphQL::STRING_TYPE, null: true + field :merge_commit_message, GraphQL::STRING_TYPE, null: true + field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false + field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false + field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true + field :web_url, GraphQL::STRING_TYPE, null: true + field :upvotes, GraphQL::INT_TYPE, null: false + field :downvotes, GraphQL::INT_TYPE, null: false + field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 00000000000..06ed91c1658 --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,7 @@ +module Types + class MutationType < BaseObject + graphql_name "Mutation" + + # TODO: Add Mutations as fields + end +end diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb new file mode 100644 index 00000000000..934ed572e56 --- /dev/null +++ b/app/graphql/types/permission_types/base_permission_type.rb @@ -0,0 +1,38 @@ +module Types + module PermissionTypes + class BasePermissionType < BaseObject + extend Gitlab::Allowable + + RESOLVING_KEYWORDS = [:resolver, :method, :hash_key, :function].to_set.freeze + + def self.abilities(*abilities) + abilities.each { |ability| ability_field(ability) } + end + + def self.ability_field(ability, **kword_args) + unless resolving_keywords?(kword_args) + kword_args[:resolve] ||= -> (object, args, context) do + can?(context[:current_user], ability, object, args.to_h) + end + end + + permission_field(ability, **kword_args) + end + + def self.permission_field(name, **kword_args) + kword_args = kword_args.reverse_merge( + name: name, + type: GraphQL::BOOLEAN_TYPE, + description: "Whether or not a user can perform `#{name}` on this resource", + null: false) + + field(**kword_args) + end + + def self.resolving_keywords?(arguments) + RESOLVING_KEYWORDS.intersect?(arguments.keys.to_set) + end + private_class_method :resolving_keywords? + end + end +end diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb new file mode 100644 index 00000000000..5c21f6ee9c6 --- /dev/null +++ b/app/graphql/types/permission_types/merge_request.rb @@ -0,0 +1,17 @@ +module Types + module PermissionTypes + class MergeRequest < BasePermissionType + present_using MergeRequestPresenter + description 'Check permissions for the current user on a merge request' + graphql_name 'MergeRequestPermissions' + + abilities :read_merge_request, :admin_merge_request, + :update_merge_request, :create_note + + permission_field :push_to_source_branch, method: :can_push_to_source_branch? + permission_field :remove_source_branch, method: :can_remove_source_branch? + permission_field :cherry_pick_on_current_merge_request, method: :can_cherry_pick_on_current_merge_request? + permission_field :revert_on_current_merge_request, method: :can_revert_on_current_merge_request? + end + end +end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb new file mode 100644 index 00000000000..755699a4415 --- /dev/null +++ b/app/graphql/types/permission_types/project.rb @@ -0,0 +1,20 @@ +module Types + module PermissionTypes + class Project < BasePermissionType + graphql_name 'ProjectPermissions' + + abilities :change_namespace, :change_visibility_level, :rename_project, + :remove_project, :archive_project, :remove_fork_project, + :remove_pages, :read_project, :create_merge_request_in, + :read_wiki, :read_project_member, :create_issue, :upload_file, + :read_cycle_analytics, :download_code, :download_wiki_code, + :fork_project, :create_project_snippet, :read_commit_status, + :request_access, :create_pipeline, :create_pipeline_schedule, + :create_merge_request_from, :create_wiki, :push_code, + :create_deployment, :push_to_delete_protected_branch, + :admin_wiki, :admin_project, :update_pages, + :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, + :create_pages, :destroy_pages + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb new file mode 100644 index 00000000000..a832e8b4bde --- /dev/null +++ b/app/graphql/types/project_type.rb @@ -0,0 +1,74 @@ +module Types + class ProjectType < BaseObject + expose_permissions Types::PermissionTypes::Project + + graphql_name 'Project' + + field :id, GraphQL::ID_TYPE, null: false + + field :full_path, GraphQL::ID_TYPE, null: false + field :path, GraphQL::STRING_TYPE, null: false + + field :name_with_namespace, GraphQL::STRING_TYPE, null: false + field :name, GraphQL::STRING_TYPE, null: false + + field :description, GraphQL::STRING_TYPE, null: true + + field :default_branch, GraphQL::STRING_TYPE, null: true + field :tag_list, GraphQL::STRING_TYPE, null: true + + field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true + field :http_url_to_repo, GraphQL::STRING_TYPE, null: true + field :web_url, GraphQL::STRING_TYPE, null: true + + field :star_count, GraphQL::INT_TYPE, null: false + field :forks_count, GraphQL::INT_TYPE, null: false + + field :created_at, Types::TimeType, null: true + field :last_activity_at, Types::TimeType, null: true + + field :archived, GraphQL::BOOLEAN_TYPE, null: true + + field :visibility, GraphQL::STRING_TYPE, null: true + + field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true + + field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (project, args, ctx) do + project.avatar_url(only_path: false) + end + + %i[issues merge_requests wiki snippets].each do |feature| + field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do + project.feature_available?(feature, ctx[:current_user]) + end + end + + field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do + project.feature_available?(:builds, ctx[:current_user]) + end + + field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true + + field :open_issues_count, GraphQL::INT_TYPE, null: true, resolve: -> (project, args, ctx) do + project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) + end + + field :import_status, GraphQL::STRING_TYPE, null: true + field :ci_config_path, GraphQL::STRING_TYPE, null: true + + field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true + field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true + field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true + + field :merge_request, + Types::MergeRequestType, + null: true, + resolver: Resolvers::MergeRequestResolver do + authorize :read_merge_request + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 00000000000..010ec2d7942 --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,14 @@ +module Types + class QueryType < BaseObject + graphql_name 'Query' + + field :project, Types::ProjectType, + null: true, + resolver: Resolvers::ProjectResolver, + description: "Find a project" do + authorize :read_project + end + + field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new + end +end diff --git a/app/graphql/types/time_type.rb b/app/graphql/types/time_type.rb new file mode 100644 index 00000000000..2333d82ad1e --- /dev/null +++ b/app/graphql/types/time_type.rb @@ -0,0 +1,14 @@ +module Types + class TimeType < BaseScalar + graphql_name 'Time' + description 'Time represented in ISO 8601' + + def self.coerce_input(value, ctx) + Time.parse(value) + end + + def self.coerce_result(value, ctx) + value.iso8601 + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f5d94ad96a1..0190aa90763 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -270,7 +270,7 @@ module ApplicationHelper { members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), issues: issues_project_autocomplete_sources_path(object), - merge_requests: merge_requests_project_autocomplete_sources_path(object), + mergeRequests: merge_requests_project_autocomplete_sources_path(object), labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), milestones: milestones_project_autocomplete_sources_path(object), commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]) diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index adc423af9e1..ef1bf283d0c 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -36,7 +36,7 @@ module ApplicationSettingsHelper # Return a group of checkboxes that use Bootstrap's button plugin for a # toggle button effect. - def restricted_level_checkboxes(help_block_id, checkbox_name) + def restricted_level_checkboxes(help_block_id, checkbox_name, options = {}) Gitlab::VisibilityLevel.values.map do |level| checked = restricted_visibility_levels(true).include?(level) css_class = checked ? 'active' : '' @@ -46,6 +46,7 @@ module ApplicationSettingsHelper check_box_tag(checkbox_name, level, checked, autocomplete: 'off', 'aria-describedby' => help_block_id, + 'class' => options[:class], id: tag_name) + visibility_level_icon(level) + visibility_level_label(level) end end @@ -53,7 +54,7 @@ module ApplicationSettingsHelper # Return a group of checkboxes that use Bootstrap's button plugin for a # toggle button effect. - def import_sources_checkboxes(help_block_id) + def import_sources_checkboxes(help_block_id, options = {}) Gitlab::ImportSources.options.map do |name, source| checked = Gitlab::CurrentSettings.import_sources.include?(source) css_class = checked ? 'active' : '' @@ -63,6 +64,7 @@ module ApplicationSettingsHelper check_box_tag(checkbox_name, source, checked, autocomplete: 'off', 'aria-describedby' => help_block_id, + 'class' => options[:class], id: name.tr(' ', '_')) + name end end diff --git a/app/helpers/favicon_helper.rb b/app/helpers/favicon_helper.rb new file mode 100644 index 00000000000..3a5342a8d9d --- /dev/null +++ b/app/helpers/favicon_helper.rb @@ -0,0 +1,7 @@ +module FaviconHelper + def favicon_extension_whitelist + FaviconUploader::EXTENSION_WHITELIST + .map { |extension| "'.#{extension}'"} + .to_sentence + end +end diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index 58372edff3c..2f304b040c7 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -60,7 +60,7 @@ module IconsHelper def spinner(text = nil, visible = false) css_class = 'loading' - css_class << ' hidden' unless visible + css_class << ' hide' unless visible content_tag :div, class: css_class do icon('spinner spin') + text diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 8572c2b7276..9f501ea55fb 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -157,7 +157,7 @@ module IssuablesHelper output = "" output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe output << content_tag(:strong) do - author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline-block", tooltip: true) + author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true) author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none") end @@ -165,7 +165,7 @@ module IssuablesHelper output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip', title: _('1st contribution!')) output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block") - output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none d-lg-none d-xl-inline-block") + output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none") output.html_safe end diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index e1b0e7a4a3e..c7df25cecef 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -211,6 +211,14 @@ module LabelsHelper end end + def label_status_tooltip(label, status) + type = label.is_a?(ProjectLabel) ? 'project' : 'group' + level = status.unsubscribed? ? type : status.sub('-level', '') + action = status.unsubscribed? ? 'Subscribe' : 'Unsubscribe' + + "#{action} at #{level} level" + end + # Required for Banzai::Filter::LabelReferenceFilter module_function :render_colored_label, :text_color_for_bg, :escape_once end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 74251c260f0..097be8a0643 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -86,6 +86,8 @@ module MergeRequestsHelper end def version_index(merge_request_diff) + return nil if @merge_request_diffs.empty? + @merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff) end @@ -106,7 +108,7 @@ module MergeRequestsHelper data_attrs = { action: tab.to_s, target: "##{tab}", - toggle: options.fetch(:force_link, false) ? '' : 'tab' + toggle: options.fetch(:force_link, false) ? '' : 'tabvue' } url = case tab @@ -126,8 +128,8 @@ module MergeRequestsHelper link_to(url[merge_request.project, merge_request], data: data_attrs, &block) end - def allow_maintainer_push_unavailable_reason(merge_request) - return if merge_request.can_allow_maintainer_to_push?(current_user) + def allow_collaboration_unavailable_reason(merge_request) + return if merge_request.can_allow_collaboration?(current_user) minimum_visibility = [merge_request.target_project.visibility_level, merge_request.source_project.visibility_level].min diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 7f67574a428..3fa2e5452c8 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -143,7 +143,15 @@ module NotesHelper notesIds: @notes.map(&:id), now: Time.now.to_i, diffView: diff_view, - autocomplete: autocomplete + enableGFM: { + emojis: true, + members: autocomplete, + issues: autocomplete, + mergeRequests: autocomplete, + epics: autocomplete, + milestones: autocomplete, + labels: autocomplete + } } end @@ -174,11 +182,11 @@ module NotesHelper discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved' end - def has_vue_discussions_cookie? - cookies[:vue_mr_discussions] == 'true' + def rendered_for_merge_request? + params[:from_merge_request].present? end def serialize_notes? - has_vue_discussions_cookie? && !params['html'] + rendered_for_merge_request? || params['html'].nil? end end diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index a8397b03d63..68d892393ef 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -39,10 +39,7 @@ module PageLayoutHelper end def favicon - return 'favicon-yellow.ico' if Gitlab::Utils.to_boolean(ENV['CANARY']) - return 'favicon-blue.ico' if Rails.env.development? - - 'favicon.ico' + Gitlab::Favicon.main end def page_image diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 55078e1a2d2..c7a434ea092 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -40,7 +40,8 @@ module ProjectsHelper name_tag_options[:class] << 'has-tooltip' end - content_tag(:span, sanitize(username), name_tag_options) + # NOTE: ActionView::Helpers::TagHelper#content_tag HTML escapes username + content_tag(:span, username, name_tag_options) end def link_to_member(project, author, opts = {}, &block) @@ -171,11 +172,12 @@ module ProjectsHelper key = [ project.route.cache_key, project.cache_key, + project.last_activity_date, controller.controller_name, controller.action_name, Gitlab::CurrentSettings.cache_key, "cross-project:#{can?(current_user, :read_cross_project)}", - 'v2.5' + 'v2.6' ] key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status? @@ -238,6 +240,14 @@ module ProjectsHelper "git push --set-upstream #{repository_url}/$(git rev-parse --show-toplevel | xargs basename).git $(git rev-parse --abbrev-ref HEAD)" end + def show_xcode_link?(project = @project) + browser.platform.mac? && project.repository.xcode_project? + end + + def xcode_uri_to_repo(project = @project) + "xcode://clone?repo=#{CGI.escape(default_url_to_repo(project))}" + end + private def get_project_nav_tabs(project, current_user) @@ -341,11 +351,15 @@ module ProjectsHelper if allowed_protocols_present? enabled_protocol else - if !current_user || current_user.require_ssh_key? - gitlab_config.protocol - else - 'ssh' - end + extra_default_clone_protocol + end + end + + def extra_default_clone_protocol + if !current_user || current_user.require_ssh_key? + gitlab_config.protocol + else + 'ssh' end end @@ -381,11 +395,11 @@ module ProjectsHelper def project_status_css_class(status) case status when "started" - "active" + "table-active" when "failed" - "danger" + "table-danger" when "finished" - "success" + "table-success" end end @@ -398,13 +412,17 @@ module ProjectsHelper @ref || @repository.try(:root_ref) end + # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/1235 def sanitize_repo_path(project, message) return '' unless message.present? exports_path = File.join(Settings.shared['path'], 'tmp/project_exports') filtered_message = message.strip.gsub(exports_path, "[REPO EXPORT PATH]") - disk_path = Gitlab.config.repositories.storages[project.repository_storage].legacy_disk_path + disk_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do + Gitlab.config.repositories.storages[project.repository_storage].legacy_disk_path + end + filtered_message.gsub(disk_path.chomp('/'), "[REPOS PATH]") end @@ -488,4 +506,45 @@ module ProjectsHelper "list-label" end end + + def sidebar_projects_paths + %w[ + projects#show + projects#activity + cycle_analytics#show + ] + end + + def sidebar_settings_paths + %w[ + projects#edit + project_members#index + integrations#show + services#edit + repository#show + ci_cd#show + badges#index + pages#show + ] + end + + def sidebar_repository_paths + %w[ + tree + blob + blame + edit_tree + new_tree + find_file + commit + commits + compare + projects/repositories + tags + branches + releases + graphs + network + ] + end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 761c1252fc8..f7dafca7834 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -25,14 +25,22 @@ module SearchHelper return unless collection.count > 0 from = collection.offset_value + 1 - to = collection.offset_value + collection.length + to = collection.offset_value + collection.count count = collection.total_count "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\"" end + def find_project_for_result_blob(result) + @project + end + def parse_search_result(result) - Gitlab::ProjectSearchResults.parse_search_result(result) + result + end + + def search_blob_title(project, filename) + filename end private diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index 9f78b80c71d..a82271ce0ee 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -6,7 +6,7 @@ module WorkhorseHelper headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob)) headers['Content-Disposition'] = 'inline' headers['Content-Type'] = safe_content_type(blob) - head :ok # 'render nothing: true' messes up the Content-Type + render plain: "" end # Send a Git diff through Workhorse diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 5ba3a4a322c..70509e9066d 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -59,8 +59,6 @@ module Emails def merge_request_unmergeable_email(recipient_id, merge_request_id, reason = nil) setup_merge_request_mail(merge_request_id, recipient_id) - @reasons = MergeRequestPresenter.new(@merge_request, current_user: current_user).unmergeable_reasons - mail_answer_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason)) end diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 67cc84a9140..b770aadef0e 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -14,6 +14,7 @@ class Appearance < ActiveRecord::Base mount_uploader :logo, AttachmentUploader mount_uploader :header_logo, AttachmentUploader + mount_uploader :favicon, FaviconUploader # Overrides CacheableAttributes.current_without_cache def self.current_without_cache diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 3d58a14882f..bddeb8b0352 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -212,14 +212,6 @@ class ApplicationSetting < ActiveRecord::Base end end - validates_each :disabled_oauth_sign_in_sources do |record, attr, value| - value&.each do |source| - unless Devise.omniauth_providers.include?(source.to_sym) - record.errors.add(attr, "'#{source}' is not an OAuth sign-in source") - end - end - end - validate :terms_exist, if: :enforce_terms? before_validation :ensure_uuid! @@ -330,6 +322,11 @@ class ApplicationSetting < ActiveRecord::Base ::Gitlab::Database.cached_column_exists?(:application_settings, :sidekiq_throttling_enabled) end + def disabled_oauth_sign_in_sources=(sources) + sources = (sources || []).map(&:to_s) & Devise.omniauth_providers.map(&:to_s) + super(sources) + end + def domain_whitelist_raw self.domain_whitelist&.join("\n") end diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb index e8ce0ccbb71..3b1dfe7e4ef 100644 --- a/app/models/application_setting/term.rb +++ b/app/models/application_setting/term.rb @@ -1,6 +1,7 @@ class ApplicationSetting class Term < ActiveRecord::Base include CacheMarkdownField + has_many :term_agreements validates :terms, presence: true @@ -9,5 +10,10 @@ class ApplicationSetting def self.latest order(:id).last end + + def accepted_by_user?(user) + user.accepted_term_id == id || + term_agreements.accepted.where(user: user).exists? + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 75fd55a8f7b..41446946a5e 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -55,12 +55,18 @@ module Ci where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) end + + scope :without_archived_trace, ->() do + where('NOT EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').trace) + end + scope :with_artifacts_stored_locally, -> { with_artifacts_archive.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) } scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } scope :ref_protected, -> { where(protected: true) } + scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where('ci_builds.id = ci_build_trace_chunks.build_id').select(1)) } scope :matches_tag_ids, -> (tag_ids) do matcher = ::ActsAsTaggableOn::Tagging @@ -144,6 +150,7 @@ module Ci after_transition any => [:success] do |build| build.run_after_commit do BuildSuccessWorker.perform_async(id) + PagesWorker.perform_async(:deploy, id) if build.pages_generator? end end @@ -183,6 +190,11 @@ module Ci pipeline.manual_actions.where.not(name: name) end + def pages_generator? + Gitlab.config.pages.enabled && + self.name == 'pages' + end + def playable? action? && (manual? || retryable?) end @@ -402,8 +414,6 @@ module Ci build_data = Gitlab::DataBuilder::Build.build(self) project.execute_hooks(build_data.dup, :job_hooks) project.execute_services(build_data.dup, :job_hooks) - PagesService.new(build_data).execute - project.running_or_pending_build_count(force: true) end def browsable_artifacts? @@ -601,6 +611,7 @@ module Ci variables .concat(pipeline.persisted_variables) .append(key: 'CI_JOB_ID', value: id.to_s) + .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self)) .append(key: 'CI_JOB_TOKEN', value: token, public: false) .append(key: 'CI_BUILD_ID', value: id.to_s) .append(key: 'CI_BUILD_TOKEN', value: token, public: false) diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb index 87898b086c6..9c1046e8715 100644 --- a/app/models/ci/group.rb +++ b/app/models/ci/group.rb @@ -31,6 +31,14 @@ module Ci end end + def self.fabricate(stage) + stage.statuses.ordered.latest + .sort_by(&:sortable_name).group_by(&:group_name) + .map do |group_name, grouped_statuses| + self.new(stage, name: group_name, jobs: grouped_statuses) + end + end + private def commit_statuses diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb index 9b536af672b..ce691875e42 100644 --- a/app/models/ci/legacy_stage.rb +++ b/app/models/ci/legacy_stage.rb @@ -16,11 +16,7 @@ module Ci end def groups - @groups ||= statuses.ordered.latest - .sort_by(&:sortable_name).group_by(&:group_name) - .map do |group_name, grouped_statuses| - Ci::Group.new(self, name: group_name, jobs: grouped_statuses) - end + @groups ||= Ci::Group.fabricate(self) end def to_param diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 53af87a271a..e5caa3ffa41 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -7,13 +7,19 @@ module Ci include Presentable include Gitlab::OptimisticLocking include Gitlab::Utils::StrongMemoize + include AtomicInternalId + include EnumWithNil belongs_to :project, inverse_of: :pipelines belongs_to :user belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule' - has_many :stages + has_internal_id :iid, scope: :project, presence: false, init: ->(s) do + s&.project&.pipelines&.maximum(:iid) || s&.project&.pipelines&.count + end + + has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent @@ -49,7 +55,7 @@ module Ci after_create :keep_around_commits, unless: :importing? - enum source: { + enum_with_nil source: { unknown: nil, push: 1, web: 2, @@ -59,7 +65,7 @@ module Ci external: 6 } - enum config_source: { + enum_with_nil config_source: { unknown_source: nil, repository_source: 1, auto_devops_source: 2 @@ -249,6 +255,20 @@ module Ci stage unless stage.statuses_count.zero? end + ## + # TODO We do not completely switch to persisted stages because of + # race conditions with setting statuses gitlab-ce#23257. + # + def ordered_stages + return legacy_stages unless complete? + + if Feature.enabled?('ci_pipeline_persisted_stages') + stages + else + legacy_stages + end + end + def legacy_stages # TODO, this needs refactoring, see gitlab-ce#26481. @@ -411,7 +431,7 @@ module Ci def number_of_warnings BatchLoader.for(id).batch(default_value: 0) do |pipeline_ids, loader| - Build.where(commit_id: pipeline_ids) + ::Ci::Build.where(commit_id: pipeline_ids) .latest .failed_but_allowed .group(:commit_id) @@ -503,7 +523,8 @@ module Ci def update_status retry_optimistic_lock(self) do - case latest_builds_status + case latest_builds_status.to_s + when 'created' then nil when 'pending' then enqueue when 'running' then run when 'success' then succeed @@ -511,6 +532,9 @@ module Ci when 'canceled' then cancel when 'skipped' then skip when 'manual' then block + else + raise HasStatus::UnknownStatusError, + "Unknown status `#{latest_builds_status}`" end end end @@ -525,17 +549,21 @@ module Ci def persisted_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| - variables.append(key: 'CI_PIPELINE_ID', value: id.to_s) if persisted? + break variables unless persisted? + + variables.append(key: 'CI_PIPELINE_ID', value: id.to_s) + variables.append(key: 'CI_PIPELINE_URL', value: Gitlab::Routing.url_helpers.project_pipeline_url(project, self)) end end def predefined_variables Gitlab::Ci::Variables::Collection.new + .append(key: 'CI_PIPELINE_IID', value: iid.to_s) .append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) .append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) - .append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message) - .append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title) - .append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description) + .append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) + .append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) + .append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) end def queued_duration @@ -575,17 +603,6 @@ module Ci @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a end - # Rails 5.0 autogenerated question mark enum methods return wrong result if enum value is nil. - # They always return `false`. - # These methods overwrite autogenerated ones to return correct results. - def unknown? - Gitlab.rails5? ? source.nil? : super - end - - def unknown_source? - Gitlab.rails5? ? config_source.nil? : super - end - private def ci_yaml_from_repo diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 57edd6a4956..8c9aacca8de 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -219,10 +219,8 @@ module Ci cache_attributes(values) - if persist_cached_data? - self.assign_attributes(values) - self.save if self.changed? - end + # We save data without validation, it will always change due to `contacted_at` + self.update_columns(values) if persist_cached_data? end def pick_build!(build) diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 5a1eeb966aa..ea07f37e6c1 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -68,16 +68,44 @@ module Ci def update_status retry_optimistic_lock(self) do case statuses.latest.status + when 'created' then nil when 'pending' then enqueue when 'running' then run when 'success' then succeed when 'failed' then drop when 'canceled' then cancel when 'manual' then block - when 'skipped' then skip - else skip + when 'skipped', nil then skip + else + raise HasStatus::UnknownStatusError, + "Unknown status `#{statuses.latest.status}`" end end end + + def groups + @groups ||= Ci::Group.fabricate(self) + end + + def has_warnings? + number_of_warnings.positive? + end + + def number_of_warnings + BatchLoader.for(id).batch(default_value: 0) do |stage_ids, loader| + ::Ci::Build.where(stage_id: stage_ids) + .latest + .failed_but_allowed + .group(:stage_id) + .count + .each { |id, amount| loader.call(id, amount) } + end + end + + def detailed_status(current_user) + Gitlab::Ci::Status::Stage::Factory + .new(self, current_user) + .fabricate! + end end end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index c702c4ee807..48137c2ed68 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -3,7 +3,7 @@ module Clusters class Prometheus < ActiveRecord::Base include PrometheusAdapter - VERSION = "2.0.0".freeze + VERSION = '6.7.3'.freeze self.table_name = 'clusters_applications_prometheus' @@ -37,6 +37,7 @@ module Clusters Gitlab::Kubernetes::Helm::InstallCommand.new( name, chart: chart, + version: version, values: values ) end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 25eac5160f1..36631d57ad1 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -11,12 +11,12 @@ module Clusters attr_encrypted :password, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base, + key: Settings.attr_encrypted_db_key_base_truncated, algorithm: 'aes-256-cbc' attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base, + key: Settings.attr_encrypted_db_key_base_truncated, algorithm: 'aes-256-cbc' before_validation :enforce_namespace_to_lower_case diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb index eb2e42fd3fe..4db1bb35c12 100644 --- a/app/models/clusters/providers/gcp.rb +++ b/app/models/clusters/providers/gcp.rb @@ -11,7 +11,7 @@ module Clusters attr_encrypted :access_token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base, + key: Settings.attr_encrypted_db_key_base_truncated, algorithm: 'aes-256-cbc' validates :gcp_project_id, diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index a7d05722287..97516079b66 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -3,6 +3,7 @@ class CommitStatus < ActiveRecord::Base include Importable include AfterCommitQueue include Presentable + include EnumWithNil self.table_name = 'ci_builds' @@ -39,7 +40,7 @@ class CommitStatus < ActiveRecord::Base scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } - enum failure_reason: { + enum_with_nil failure_reason: { unknown_failure: nil, script_failure: 1, api_failure: 2, @@ -190,11 +191,4 @@ class CommitStatus < ActiveRecord::Base v =~ /\d+/ ? v.to_i : v end end - - # Rails 5.0 autogenerated question mark enum methods return wrong result if enum value is nil. - # They always return `false`. - # This method overwrites the autogenerated one to return correct result. - def unknown_failure? - Gitlab.rails5? ? failure_reason.nil? : super - end end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 22f516a172f..164c704260e 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -25,9 +25,13 @@ module AtomicInternalId extend ActiveSupport::Concern module ClassMethods - def has_internal_id(column, scope:, init:) # rubocop:disable Naming/PredicateName - before_validation(on: :create) do + def has_internal_id(column, scope:, init:, presence: true) # rubocop:disable Naming/PredicateName + before_validation :"ensure_#{scope}_#{column}!", on: :create + validates column, presence: presence + + define_method("ensure_#{scope}_#{column}!") do scope_value = association(scope).reader + if read_attribute(column).blank? && scope_value scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value } usage = self.class.table_name.to_sym @@ -35,13 +39,9 @@ module AtomicInternalId new_iid = InternalId.generate_next(self, scope_attrs, usage, init) write_attribute(column, new_iid) end - end - validates column, presence: true, numericality: true + read_attribute(column) + end end end - - def to_param - iid.to_s - end end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 13246a774e3..095897b08e3 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -4,11 +4,14 @@ module Avatarable included do prepend ShadowMethods include ObjectStorage::BackgroundMove + include Gitlab::Utils::StrongMemoize validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } mount_uploader :avatar, AvatarUploader + + after_initialize :add_avatar_to_batch end module ShadowMethods @@ -18,6 +21,17 @@ module Avatarable avatar_path(only_path: args.fetch(:only_path, true)) || super end + + def retrieve_upload(identifier, paths) + upload = retrieve_upload_from_batch(identifier) + + # This fallback is needed when deleting an upload, because we may have + # already been removed from the DB. We have to check an explicit `#nil?` + # because it's a BatchLoader instance. + upload = super if upload.nil? + + upload + end end def avatar_type @@ -52,4 +66,37 @@ module Avatarable url_base + avatar.local_url end + + # Path that is persisted in the tracking Upload model. Used to fetch the + # upload from the model. + def upload_paths(identifier) + avatar_mounter.blank_uploader.store_dirs.map { |store, path| File.join(path, identifier) } + end + + private + + def retrieve_upload_from_batch(identifier) + BatchLoader.for(identifier: identifier, model: self).batch(key: self.class) do |upload_params, loader, args| + model_class = args[:key] + paths = upload_params.flat_map do |params| + params[:model].upload_paths(params[:identifier]) + end + + Upload.where(uploader: AvatarUploader, path: paths).find_each do |upload| + model = model_class.instantiate('id' => upload.model_id) + + loader.call({ model: model, identifier: File.basename(upload.path) }, upload) + end + end + end + + def add_avatar_to_batch + return unless avatar_mounter + + avatar_mounter.read_identifiers.each { |identifier| retrieve_upload_from_batch(identifier) } + end + + def avatar_mounter + strong_memoize(:avatar_mounter) { _mounter(:avatar) } + end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index db8cf322ef7..9f6358cecbe 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -114,7 +114,7 @@ module CacheMarkdownField end def latest_cached_markdown_version - return CacheMarkdownField::CACHE_REDCARPET_VERSION unless cached_markdown_version + return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START CacheMarkdownField::CACHE_REDCARPET_VERSION diff --git a/app/models/concerns/enum_with_nil.rb b/app/models/concerns/enum_with_nil.rb new file mode 100644 index 00000000000..6b37903da20 --- /dev/null +++ b/app/models/concerns/enum_with_nil.rb @@ -0,0 +1,33 @@ +module EnumWithNil + extend ActiveSupport::Concern + + included do + def self.enum_with_nil(definitions) + # use original `enum` to auto-define all methods + enum(definitions) + + # override auto-defined methods only for the + # key which uses nil value + definitions.each do |name, values| + next unless key_with_nil = values.key(nil) + + # E.g. for enum_with_nil failure_reason: { unknown_failure: nil } + # this overrides auto-generated method `unknown_failure?` + define_method("#{key_with_nil}?") do + Gitlab.rails5? ? self[name].nil? : super() + end + + # E.g. for enum_with_nil failure_reason: { unknown_failure: nil } + # this overrides auto-generated method `failure_reason` + define_method(name) do + orig = super() + + return orig unless Gitlab.rails5? + return orig unless orig.nil? + + self.class.public_send(name.to_s.pluralize).key(nil) # rubocop:disable GitlabSecurity/PublicSend + end + end + end + end +end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index 7c3ed96bc28..72c236a0fc7 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -11,6 +11,8 @@ module HasStatus STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7 }.freeze + UnknownStatusError = Class.new(StandardError) + class_methods do def status_sql scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all diff --git a/app/models/concerns/iid_routes.rb b/app/models/concerns/iid_routes.rb new file mode 100644 index 00000000000..246748cf52c --- /dev/null +++ b/app/models/concerns/iid_routes.rb @@ -0,0 +1,9 @@ +module IidRoutes + ## + # This automagically enforces all related routes to use `iid` instead of `id` + # If you want to use `iid` for some routes and `id` for other routes, this module should not to be included, + # instead you should define `iid` or `id` explictly at each route generators. e.g. pipeline_path(project.id, pipeline.iid) + def to_param + iid.to_s + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 44150b37708..b93c1145f82 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -107,6 +107,10 @@ module Issuable false end + def etag_caching_enabled? + false + end + def has_multiple_assignees? assignees.count > 1 end diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index bfda5b1678b..e3a7f2d5498 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -8,8 +8,8 @@ module ProtectedRefAccess ].freeze HUMAN_ACCESS_LEVELS = { - Gitlab::Access::MASTER => "Masters".freeze, - Gitlab::Access::DEVELOPER => "Developers + Masters".freeze, + Gitlab::Access::MASTER => "Maintainers".freeze, + Gitlab::Access::DEVELOPER => "Developers + Maintainers".freeze, Gitlab::Access::NO_ACCESS => "No one".freeze }.freeze diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb index b5425295130..3bdc1330d23 100644 --- a/app/models/concerns/redis_cacheable.rb +++ b/app/models/concerns/redis_cacheable.rb @@ -48,7 +48,7 @@ module RedisCacheable def cast_value_from_cache(attribute, value) if Gitlab.rails5? - self.class.type_for_attribute(attribute).cast(value) + self.class.type_for_attribute(attribute.to_s).cast(value) else self.class.column_for_attribute(attribute).type_cast_from_database(value) end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index db7254c27e0..cb76ae971d4 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -12,8 +12,8 @@ module Sortable scope :order_created_asc, -> { reorder(created_at: :asc) } scope :order_updated_desc, -> { reorder(updated_at: :desc) } scope :order_updated_asc, -> { reorder(updated_at: :asc) } - scope :order_name_asc, -> { reorder("lower(name) asc") } - scope :order_name_desc, -> { reorder("lower(name) desc") } + scope :order_name_asc, -> { reorder(Arel::Nodes::Ascending.new(arel_table[:name].lower)) } + scope :order_name_desc, -> { reorder(Arel::Nodes::Descending.new(arel_table[:name].lower)) } end module ClassMethods diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index e7cfffb775b..4245d083a49 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -36,4 +36,8 @@ module WithUploads upload.destroy end end + + def retrieve_upload(_identifier, paths) + uploads.find_by(path: paths) + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 254764eefde..ac86e9e8de0 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -1,5 +1,6 @@ class Deployment < ActiveRecord::Base include AtomicInternalId + include IidRoutes belongs_to :project, required: true belongs_to :environment, required: true diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 92482a1a875..35a0ef00856 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -17,6 +17,10 @@ class Discussion to: :first_note + def project_id + project&.id + end + def self.build(notes, context_noteable = nil) notes.first.discussion_class(context_noteable).new(notes, context_noteable) end diff --git a/app/models/group.rb b/app/models/group.rb index 8fb77a7869d..9c171de7fc3 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -11,6 +11,7 @@ class Group < Namespace include GroupDescendant include TokenAuthenticatable include WithUploads + include Gitlab::Utils::StrongMemoize has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members @@ -26,7 +27,11 @@ class Group < Namespace has_many :milestones has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :shared_projects, through: :project_group_links, source: :project + + # Overridden on another method + # Left here just to be dependent: :destroy has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent + has_many :labels, class_name: 'GroupLabel' has_many :variables, class_name: 'Ci::GroupVariable' has_many :custom_attributes, class_name: 'GroupCustomAttribute' @@ -88,6 +93,15 @@ class Group < Namespace end end + # Overrides notification_settings has_many association + # This allows to apply notification settings from parent groups + # to child groups and projects. + def notification_settings + source_type = self.class.base_class.name + + NotificationSetting.where(source_type: source_type, source_id: self_and_ancestors_ids) + end + def to_reference(_from = nil, full: nil) "#{self.class.reference_prefix}#{full_path}" end @@ -141,13 +155,14 @@ class Group < Namespace ) end - def add_user(user, access_level, current_user: nil, expires_at: nil) + def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false) GroupMember.add_user( self, user, access_level, current_user: current_user, - expires_at: expires_at + expires_at: expires_at, + ldap: ldap ) end @@ -195,6 +210,10 @@ class Group < Namespace owners.include?(user) && owners.size == 1 end + def ldap_synced? + false + end + def post_create_hook Gitlab::AppLogger.info("Group \"#{name}\" was created") @@ -220,6 +239,12 @@ class Group < Namespace members_with_parents.pluck(:user_id) end + def self_and_ancestors_ids + strong_memoize(:self_and_ancestors_ids) do + self_and_ancestors.pluck(:id) + end + end + def members_with_parents # Avoids an unnecessary SELECT when the group has no parents source_ids = diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index f7f930e86ed..f50f28deffe 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -14,7 +14,7 @@ class InternalId < ActiveRecord::Base belongs_to :project belongs_to :namespace - enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4 } + enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5 } validates :usage, presence: true diff --git a/app/models/issue.rb b/app/models/issue.rb index 41a290f34b4..4715d942c8d 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -2,6 +2,7 @@ require 'carrierwave/orm/activerecord' class Issue < ActiveRecord::Base include AtomicInternalId + include IidRoutes include Issuable include Noteable include Referable @@ -47,7 +48,7 @@ class Issue < ActiveRecord::Base scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} - scope :with_due_date, -> { where('due_date IS NOT NULL') } + scope :with_due_date, -> { where.not(due_date: nil) } scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } @@ -55,7 +56,7 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } - scope :order_closest_future_date, -> { reorder('CASE WHEN due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - due_date) ASC') } + scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') } scope :preload_associations, -> { preload(:labels, project: :namespace) } @@ -307,6 +308,10 @@ class Issue < ActiveRecord::Base end end + def etag_caching_enabled? + true + end + def discussions_rendered_on_frontend? true end diff --git a/app/models/label.rb b/app/models/label.rb index de7f1d56c64..7bbcaa121ca 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -85,11 +85,16 @@ class Label < ActiveRecord::Base (#{Project.reference_pattern})? #{Regexp.escape(reference_prefix)} (?: - (?<label_id>\d+(?!\S\w)\b) | # Integer-based label ID, or - (?<label_name> - [A-Za-z0-9_\-\?\.&]+ | # String-based single-word label title, or - ".+?" # String-based multi-word label surrounded in quotes - ) + (?<label_id>\d+(?!\S\w)\b) + | # Integer-based label ID, or + (?<label_name> + # String-based single-word label title, or + [A-Za-z0-9_\-\?\.&]+ + (?<!\.|\?) + | + # String-based multi-word label surrounded in quotes + ".+?" + ) ) }x end @@ -137,6 +142,10 @@ class Label < ActiveRecord::Base priority.try(:priority) end + def priority? + priorities.present? + end + def template? template end diff --git a/app/models/list.rb b/app/models/list.rb index 5daf35ef845..4edcfa78835 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -2,17 +2,27 @@ class List < ActiveRecord::Base belongs_to :board belongs_to :label - enum list_type: { backlog: 0, label: 1, closed: 2 } + enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3 } validates :board, :list_type, presence: true validates :label, :position, presence: true, if: :label? validates :label_id, uniqueness: { scope: :board_id }, if: :label? - validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label? + validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :movable? before_destroy :can_be_destroyed - scope :destroyable, -> { where(list_type: list_types[:label]) } - scope :movable, -> { where(list_type: list_types[:label]) } + scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) } + scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) } + + class << self + def destroyable_types + [:label] + end + + def movable_types + [:label] + end + end def destroyable? label? diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 79fc155fd3c..b4090fd8baf 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,5 +1,6 @@ class MergeRequest < ActiveRecord::Base include AtomicInternalId + include IidRoutes include Issuable include Noteable include Referable @@ -127,8 +128,10 @@ class MergeRequest < ActiveRecord::Base end after_transition unchecked: :cannot_be_merged do |merge_request, transition| - NotificationService.new.merge_request_unmergeable(merge_request) - TodoService.new.merge_request_became_unmergeable(merge_request) + if merge_request.notify_conflict? + NotificationService.new.merge_request_unmergeable(merge_request) + TodoService.new.merge_request_became_unmergeable(merge_request) + end end def check_state?(merge_status) @@ -368,6 +371,10 @@ class MergeRequest < ActiveRecord::Base end end + def non_latest_diffs + merge_request_diffs.where.not(id: merge_request_diff.id) + end + def diff_size # Calling `merge_request_diff.diffs.real_size` will also perform # highlighting, which we don't need here. @@ -609,18 +616,7 @@ class MergeRequest < ActiveRecord::Base def reload_diff(current_user = nil) return unless open? - old_diff_refs = self.diff_refs - new_diff = create_merge_request_diff - - MergeRequests::MergeRequestDiffCacheService.new.execute(self, new_diff) - - new_diff_refs = self.diff_refs - - update_diff_discussion_positions( - old_diff_refs: old_diff_refs, - new_diff_refs: new_diff_refs, - current_user: current_user - ) + MergeRequests::ReloadDiffsService.new(self, current_user).execute end def check_if_can_be_merged @@ -705,6 +701,17 @@ class MergeRequest < ActiveRecord::Base should_remove_source_branch? || force_remove_source_branch? end + def notify_conflict? + (opened? || locked?) && + has_commits? && + !branch_missing? && + !project.repository.can_be_merged?(diff_head_sha, target_branch) + rescue Gitlab::Git::CommandError + # Checking mergeability can trigger exception, e.g. non-utf8 + # We ignore this type of errors. + false + end + def related_notes # Fetch comments only from last 100 commits commits_for_notes_limit = 100 @@ -1114,6 +1121,10 @@ class MergeRequest < ActiveRecord::Base true end + def discussions_rendered_on_frontend? + true + end + def update_project_counter_caches Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache end @@ -1124,21 +1135,24 @@ class MergeRequest < ActiveRecord::Base project.merge_requests.merged.where(author_id: author_id).empty? end - def allow_maintainer_to_push - maintainer_push_possible? && super + # TODO: remove once production database rename completes + alias_attribute :allow_collaboration, :allow_maintainer_to_push + + def allow_collaboration + collaborative_push_possible? && allow_maintainer_to_push end - alias_method :allow_maintainer_to_push?, :allow_maintainer_to_push + alias_method :allow_collaboration?, :allow_collaboration - def maintainer_push_possible? + def collaborative_push_possible? source_project.present? && for_fork? && target_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && source_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && !ProtectedBranch.protected?(source_project, source_branch) end - def can_allow_maintainer_to_push?(user) - maintainer_push_possible? && + def can_allow_collaboration?(user) + collaborative_push_possible? && Ability.allowed?(user, :push_code, source_project) end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 06aa67c600f..3d72c447b4b 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -3,6 +3,7 @@ class MergeRequestDiff < ActiveRecord::Base include Importable include ManualInverseAssociation include IgnorableColumn + include EachBatch # Don't display more than 100 commits at once COMMITS_SAFE_SIZE = 100 @@ -17,8 +18,14 @@ class MergeRequestDiff < ActiveRecord::Base has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) } state_machine :state, initial: :empty do + event :clean do + transition any => :without_files + end + state :collected state :overflow + # Diff files have been deleted by the system + state :without_files # Deprecated states: these are no longer used but these values may still occur # in the database. state :timeout @@ -27,6 +34,7 @@ class MergeRequestDiff < ActiveRecord::Base state :overflow_diff_lines_limit end + scope :with_files, -> { without_states(:without_files, :empty) } scope :viewable, -> { without_state(:empty) } scope :by_commit_sha, ->(sha) do joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil) @@ -42,6 +50,10 @@ class MergeRequestDiff < ActiveRecord::Base find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha) end + def viewable? + collected? || without_files? || overflow? + end + # Collect information about commits and diff from repository # and save it to the database as serialized data def save_git_content @@ -170,6 +182,21 @@ class MergeRequestDiff < ActiveRecord::Base end def diffs(diff_options = nil) + if without_files? && comparison = diff_refs.compare_in(project) + # It should fetch the repository when diffs are cleaned by the system. + # We don't keep these for storage overload purposes. + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/37639 + comparison.diffs(diff_options) + else + diffs_collection(diff_options) + end + end + + # Should always return the DB persisted diffs collection + # (e.g. Gitlab::Diff::FileCollection::MergeRequestDiff. + # It's useful when trying to invalidate old caches through + # FileCollection::MergeRequestDiff#clear_cache! + def diffs_collection(diff_options = nil) Gitlab::Diff::FileCollection::MergeRequestDiff.new(self, diff_options: diff_options) end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index d14e3a4ded5..d05dcfd083a 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -9,6 +9,7 @@ class Milestone < ActiveRecord::Base include CacheMarkdownField include AtomicInternalId + include IidRoutes include Sortable include Referable include StripAttribute diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 52fe529c016..7034c633268 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -228,6 +228,10 @@ class Namespace < ActiveRecord::Base parent.present? end + def root_ancestor + ancestors.reorder(nil).find_by(parent_id: nil) + end + def subgroup? has_parent? end diff --git a/app/models/note.rb b/app/models/note.rb index 02f7a9b1e4f..abc40d9016e 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -384,6 +384,7 @@ class Note < ActiveRecord::Base def expire_etag_cache return unless noteable&.discussions_rendered_on_frontend? + return unless noteable&.etag_caching_enabled? Gitlab::EtagCaching::Store.new.touch(etag_key) end @@ -435,6 +436,10 @@ class Note < ActiveRecord::Base super.merge(noteable: noteable) end + def retrieve_upload(_identifier, paths) + Upload.find_by(model: self, path: paths) + end + private def keep_around_commit diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 2c3580bbdc6..1a03dd9df56 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -1,4 +1,6 @@ class NotificationRecipient + include Gitlab::Utils::StrongMemoize + attr_reader :user, :type, :reason def initialize(user, type, **opts) unless NotificationSetting.levels.key?(type) || type == :subscription @@ -64,7 +66,7 @@ class NotificationRecipient return false unless @target return false unless @target.respond_to?(:subscriptions) - subscription = @target.subscriptions.find_by_user_id(@user.id) + subscription = @target.subscriptions.find { |subscription| subscription.user_id == @user.id } subscription && !subscription.subscribed end @@ -142,10 +144,33 @@ class NotificationRecipient return project_setting unless project_setting.nil? || project_setting.global? - group_setting = @group && user.notification_settings_for(@group) + group_setting = closest_non_global_group_notification_settting - return group_setting unless group_setting.nil? || group_setting.global? + return group_setting unless group_setting.nil? user.global_notification_setting end + + # Returns the notificaton_setting of the lowest group in hierarchy with non global level + def closest_non_global_group_notification_settting + return unless @group + return if indexed_group_notification_settings.empty? + + notification_setting = nil + + @group.self_and_ancestors_ids.each do |id| + notification_setting = indexed_group_notification_settings[id] + break if notification_setting + end + + notification_setting + end + + def indexed_group_notification_settings + strong_memoize(:indexed_group_notification_settings) do + @group.notification_settings.where(user_id: user.id) + .where.not(level: NotificationSetting.levels[:global]) + .index_by(&:source_id) + end + end end diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb index 82c1c4de3a0..355624fd552 100644 --- a/app/models/personal_snippet.rb +++ b/app/models/personal_snippet.rb @@ -1,2 +1,3 @@ class PersonalSnippet < Snippet + include WithUploads end diff --git a/app/models/project.rb b/app/models/project.rb index 32298fc7f5c..d91d7dcfe9a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -25,6 +25,7 @@ class Project < ActiveRecord::Base include FastDestroyAll::Helpers include WithUploads include BatchDestroyDependentAssociations + extend Gitlab::Cache::RequestCache extend Gitlab::ConfigHelper @@ -68,7 +69,7 @@ class Project < ActiveRecord::Base add_authentication_token_field :runners_token - before_validation :mark_remote_mirrors_for_removal, if: -> { ActiveRecord::Base.connection.table_exists?(:remote_mirrors) } + before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? } before_save :ensure_runners_token @@ -228,6 +229,7 @@ class Project < ActiveRecord::Base has_many :commit_statuses has_many :pipelines, class_name: 'Ci::Pipeline', inverse_of: :project + has_many :stages, class_name: 'Ci::Stage', inverse_of: :project # Ci::Build objects store data on the file system such as artifact files and # build traces. Currently there's no efficient way of removing this data in @@ -291,6 +293,7 @@ class Project < ActiveRecord::Base validates :name, uniqueness: { scope: :namespace_id } validates :import_url, url: { protocols: %w(http https ssh git), allow_localhost: false, + enforce_user: true, ports: VALID_IMPORT_PORTS }, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create @@ -674,6 +677,12 @@ class Project < ActiveRecord::Base end end + def human_import_status_name + ensure_import_state + + import_state.human_status_name + end + def import_schedule ensure_import_state(force: true) @@ -1425,8 +1434,14 @@ class Project < ActiveRecord::Base Ci::Runner.from("(#{union.to_sql}) ci_runners") end + def active_runners + strong_memoize(:active_runners) do + all_runners.active + end + end + def any_runners?(&block) - all_runners.active.any?(&block) + active_runners.any?(&block) end def valid_runners_token?(token) @@ -1602,6 +1617,7 @@ class Project < ActiveRecord::Base def after_import repository.after_import + wiki.repository.after_import import_finish remove_import_jid update_project_counter_caches @@ -1649,12 +1665,6 @@ class Project < ActiveRecord::Base import_state.update_column(:jid, nil) end - def running_or_pending_build_count(force: false) - Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do - builds.running_or_pending.count(:all) - end - end - # Lazy loading of the `pipeline_status` attribute def pipeline_status @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self) @@ -1974,18 +1984,18 @@ class Project < ActiveRecord::Base .limit(1) .select(1) source_of_merge_requests.opened - .where(allow_maintainer_to_push: true) + .where(allow_collaboration: true) .where('EXISTS (?)', developer_access_exists) end - def branch_allows_maintainer_push?(user, branch_name) + def branch_allows_collaboration?(user, branch_name) return false unless user cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push" - memoized_results = strong_memoize(:branch_allows_maintainer_push) do + memoized_results = strong_memoize(:branch_allows_collaboration) do Hash.new do |result, cache_key| - result[cache_key] = fetch_branch_allows_maintainer_push?(user, branch_name) + result[cache_key] = fetch_branch_allows_collaboration?(user, branch_name) end end @@ -2004,6 +2014,15 @@ class Project < ActiveRecord::Base @gitlab_deploy_token ||= deploy_tokens.gitlab_deploy_token end + def any_lfs_file_locks? + lfs_file_locks.any? + end + request_cache(:any_lfs_file_locks?) { self.id } + + def auto_cancel_pending_pipelines? + auto_cancel_pending_pipelines == 'enabled' + end + private def storage @@ -2127,18 +2146,22 @@ class Project < ActiveRecord::Base raise ex end - def fetch_branch_allows_maintainer_push?(user, branch_name) + def fetch_branch_allows_collaboration?(user, branch_name) check_access = -> do next false if empty_repo? - merge_request = source_of_merge_requests.opened - .where(allow_maintainer_to_push: true) - .find_by(source_branch: branch_name) - merge_request&.can_be_merged_by?(user) + merge_requests = source_of_merge_requests.opened + .where(allow_collaboration: true) + + if branch_name + merge_requests.find_by(source_branch: branch_name)&.can_be_merged_by?(user) + else + merge_requests.any? { |merge_request| merge_request.can_be_merged_by?(user) } + end end if RequestStore.active? - RequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_maintainer_push") do + RequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do check_access.call end else diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index ed6c1eddbc1..faa831b1949 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -1,11 +1,18 @@ class ProjectAutoDevops < ActiveRecord::Base belongs_to :project + enum deploy_strategy: { + continuous: 0, + manual: 1 + } + scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } + after_save :create_gitlab_deploy_token, if: :needs_to_create_deploy_token? + def instance_domain Gitlab::CurrentSettings.auto_devops_domain end @@ -20,6 +27,30 @@ class ProjectAutoDevops < ActiveRecord::Base variables.append(key: 'AUTO_DEVOPS_DOMAIN', value: domain.presence || instance_domain) end + + if manual? + variables.append(key: 'STAGING_ENABLED', value: '1') + variables.append(key: 'INCREMENTAL_ROLLOUT_ENABLED', value: '1') + end end end + + private + + def create_gitlab_deploy_token + project.deploy_tokens.create!( + name: DeployToken::GITLAB_DEPLOY_TOKEN_NAME, + read_registry: true + ) + end + + def needs_to_create_deploy_token? + auto_devops_enabled? && + !project.public? && + !project.deploy_tokens.find_by(name: DeployToken::GITLAB_DEPLOY_TOKEN_NAME).present? + end + + def auto_devops_enabled? + Gitlab::CurrentSettings.auto_devops_enabled? || enabled? + end end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 7f4c47a6d14..edc5c00d9c4 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -67,11 +67,11 @@ class BambooService < CiService def execute(data) return unless supported_events.include?(data[:object_kind]) - get_path("updateAndBuild.action?buildKey=#{build_key}") + get_path("updateAndBuild.action", { buildKey: build_key }) end def calculate_reactive_cache(sha, ref) - response = get_path("rest/api/latest/result?label=#{sha}") + response = get_path("rest/api/latest/result/byChangeset/#{sha}") { build_page: read_build_page(response), commit_status: read_commit_status(response) } end @@ -113,18 +113,20 @@ class BambooService < CiService URI.join("#{bamboo_url}/", path).to_s end - def get_path(path) + def get_path(path, query_params = {}) url = build_url(path) if username.blank? && password.blank? - Gitlab::HTTP.get(url, verify: false) + Gitlab::HTTP.get(url, verify: false, query: query_params) else - url << '&os_authType=basic' - Gitlab::HTTP.get(url, verify: false, - basic_auth: { - username: username, - password: password - }) + query_params[:os_authType] = 'basic' + Gitlab::HTTP.get(url, + verify: false, + query: query_params, + basic_auth: { + username: username, + password: password + }) end end end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 22a65b5145e..f710fa85b5d 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -26,13 +26,18 @@ module ChatMessage end end - def pretext + def summary return message if markdown format(message) end + def pretext + summary + end + def fallback + format(message) end def attachments diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 2135122278a..96fd23aede3 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -23,10 +23,6 @@ module ChatMessage '' end - def fallback - format(message) - end - def attachments return message if markdown diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index ae0debbd3ac..a60b4c7fd0d 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -155,6 +155,7 @@ class ChatNotificationService < Service end def notify_for_ref?(data) + return true if data[:object_kind] == 'tag_push' return true if data.dig(:object_attributes, :tag) return true unless notify_only_default_branch? diff --git a/app/models/project_services/gemnasium_service.rb b/app/models/project_services/gemnasium_service.rb index 84248f9590b..8a6b0ed1a5f 100644 --- a/app/models/project_services/gemnasium_service.rb +++ b/app/models/project_services/gemnasium_service.rb @@ -43,13 +43,18 @@ class GemnasiumService < Service def execute(data) return unless supported_events.include?(data[:object_kind]) + # Gitaly: this class will be removed https://gitlab.com/gitlab-org/gitlab-ee/issues/6010 + repo_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do + project.repository.path_to_repo + end + Gemnasium::GitlabService.execute( ref: data[:ref], before: data[:before], after: data[:after], token: token, api_key: api_key, - repo: project.repository.path_to_repo # Gitaly: fixed by https://gitlab.com/gitlab-org/security-products/gemnasium-migration/issues/9 + repo: repo_path ) end end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index eb3261c902f..412d62388f0 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -265,7 +265,7 @@ class JiraService < IssueTrackerService title: title, status: status, icon: { - title: 'GitLab', url16x16: asset_url('favicon.ico', host: gitlab_config.url) + title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.url) } } } diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index 2facff53e26..99500caec0e 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -44,7 +44,7 @@ class MicrosoftTeamsService < ChatNotificationService def notify(message, opts) MicrosoftTeams::Notifier.new(webhook).ping( title: message.project_name, - pretext: message.pretext, + summary: message.summary, activity: message.activity, attachments: message.attachments ) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 33280eda0b9..9a38806baab 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -24,7 +24,7 @@ class ProjectTeam end def add_role(user, role, current_user: nil) - send(:"add_#{role}", user, current_user: current_user) # rubocop:disable GitlabSecurity/PublicSend + public_send(:"add_#{role}", user, current_user: current_user) # rubocop:disable GitlabSecurity/PublicSend end def find_member(user_id) diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index f799a0b4227..a6f94b3e3b0 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -140,10 +140,6 @@ class ProjectWiki [title, title_array.join("/")] end - def search_files(query) - repository.search_files_by_content(query, default_branch) - end - def repository @repository ||= Repository.new(full_path, @project, disk_path: disk_path, is_wiki: true) end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index cb361a66591..dff99cfca35 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -5,7 +5,7 @@ class ProtectedBranch < ActiveRecord::Base protected_ref_access_levels :merge, :push def self.protected_ref_accessible_to?(ref, user, project:, action:, protected_refs: nil) - # Masters, owners and admins are allowed to create the default branch + # Maintainers, owners and admins are allowed to create the default branch if default_branch_protected? && project.empty_repo? return true if user.admin? || project.team.max_member_access(user.id) > Gitlab::Access::DEVELOPER end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 5cd222e18a4..c4b5dd2dc96 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -16,7 +16,7 @@ class RemoteMirror < ActiveRecord::Base belongs_to :project, inverse_of: :remote_mirrors - validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true } + validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } before_save :set_new_remote_name, if: :mirror_url_changed? diff --git a/app/models/repository.rb b/app/models/repository.rb index 82cf47ba04e..5f9894f1168 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -21,7 +21,7 @@ class Repository attr_accessor :full_path, :disk_path, :project, :is_wiki delegate :ref_name_for_sha, to: :raw_repository - delegate :bundle_to_disk, :create_from_bundle, to: :raw_repository + delegate :bundle_to_disk, to: :raw_repository CreateTreeError = Class.new(StandardError) @@ -99,11 +99,11 @@ class Repository "#<#{self.class.name}:#{@disk_path}>" end - def commit(ref = 'HEAD') + def commit(ref = nil) return nil unless exists? return ref if ref.is_a?(::Commit) - find_commit(ref) + find_commit(ref || root_ref) end # Finding a commit by the passed SHA @@ -154,7 +154,10 @@ class Repository # Returns a list of commits that are not present in any reference def new_commits(newrev) - refs = ::Gitlab::Git::RevList.new(raw, newrev: newrev).new_refs + # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/1233 + refs = Gitlab::GitalyClient::StorageSettings.allow_disk_access do + ::Gitlab::Git::RevList.new(raw, newrev: newrev).new_refs + end refs.map { |sha| commit(sha.strip) } end @@ -270,6 +273,20 @@ class Repository end end + def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:) + raw_repository.archive_metadata( + ref, + storage_path, + project.path, + format, + append_sha: append_sha + ) + end + + def cached_methods + CACHED_METHODS + end + def expire_tags_cache expire_method_caches(%i(tag_names tag_count)) @tags = nil @@ -410,7 +427,7 @@ class Repository # Runs code after the HEAD of a repository is changed. def after_change_head - expire_method_caches(METHOD_CACHES_FOR_FILE_TYPES.keys) + expire_all_method_caches end # Runs code after a repository has been forked/imported. @@ -837,7 +854,7 @@ class Repository @root_ref_sha ||= commit(root_ref).sha end - delegate :merged_branch_names, :can_be_merged?, to: :raw_repository + delegate :merged_branch_names, to: :raw_repository def merge_base(first_commit_id, second_commit_id) first_commit_id = commit(first_commit_id).try(:id) || first_commit_id @@ -946,6 +963,10 @@ class Repository blob_data_at(sha, path) end + def lfsconfig_for(sha) + blob_data_at(sha, '.lfsconfig') + end + def fetch_ref(source_repository, source_ref:, target_ref:) raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref) end diff --git a/app/models/term_agreement.rb b/app/models/term_agreement.rb index 8458a231bbd..c317bd0c90b 100644 --- a/app/models/term_agreement.rb +++ b/app/models/term_agreement.rb @@ -2,5 +2,7 @@ class TermAgreement < ActiveRecord::Base belongs_to :term, class_name: 'ApplicationSetting::Term' belongs_to :user + scope :accepted, -> { where(accepted: true) } + validates :user, :term, presence: true end diff --git a/app/models/timelog.rb b/app/models/timelog.rb index f4c5c581a11..659146f43e4 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -19,4 +19,9 @@ class Timelog < ActiveRecord::Base errors.add(:base, 'Issue or Merge Request ID is required') end end + + # Rails5 defaults to :touch_later, overwrite for normal touch + def belongs_to_touch_method + :touch + end end diff --git a/app/models/user.rb b/app/models/user.rb index e219ab800ad..48629c58490 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -244,7 +244,7 @@ class User < ActiveRecord::Base scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } - scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') } + scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } @@ -1038,7 +1038,10 @@ class User < ActiveRecord::Base def notification_settings_for(source) if notification_settings.loaded? - notification_settings.find { |notification| notification.source == source } + notification_settings.find do |notification| + notification.source_type == source.class.base_class.name && + notification.source_id == source.id + end else notification_settings.find_or_initialize_by(source: source) end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 8b65758f3e8..1c0cc7425ec 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -14,8 +14,8 @@ module Ci @subject.triggered_by?(@user) end - condition(:branch_allows_maintainer_push) do - @subject.project.branch_allows_maintainer_push?(@user, @subject.ref) + condition(:branch_allows_collaboration) do + @subject.project.branch_allows_collaboration?(@user, @subject.ref) end rule { protected_ref }.policy do @@ -25,7 +25,7 @@ module Ci rule { can?(:admin_build) | (can?(:update_build) & owner_of_job) }.enable :erase_build - rule { can?(:public_access) & branch_allows_maintainer_push }.policy do + rule { can?(:public_access) & branch_allows_collaboration }.policy do enable :update_build enable :update_commit_status end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 540e4235299..b81329d0625 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -4,13 +4,13 @@ module Ci condition(:protected_ref) { ref_protected?(@user, @subject.project, @subject.tag?, @subject.ref) } - condition(:branch_allows_maintainer_push) do - @subject.project.branch_allows_maintainer_push?(@user, @subject.ref) + condition(:branch_allows_collaboration) do + @subject.project.branch_allows_collaboration?(@user, @subject.ref) end rule { protected_ref }.prevent :update_pipeline - rule { can?(:public_access) & branch_allows_maintainer_push }.policy do + rule { can?(:public_access) & branch_allows_collaboration }.policy do enable :update_pipeline end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 99a0d7118f2..199bcf92b21 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -45,7 +45,7 @@ class ProjectPolicy < BasePolicy desc "User has developer access" condition(:developer) { team_access_level >= Gitlab::Access::DEVELOPER } - desc "User has master access" + desc "User has maintainer access" condition(:master) { team_access_level >= Gitlab::Access::MASTER } desc "Project is public" @@ -297,6 +297,7 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:build)) prevent(*create_read_update_admin_destroy(:pipeline_schedule)) prevent(*create_read_update_admin_destroy(:environment)) + prevent(*create_read_update_admin_destroy(:cluster)) prevent(*create_read_update_admin_destroy(:deployment)) end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index ad839d9840a..f77b3541644 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -20,17 +20,6 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end - def unmergeable_reasons - strong_memoize(:unmergeable_reasons) do - reasons = [] - reasons << "no commits" if merge_request.has_no_commits? - reasons << "source branch is missing" unless merge_request.source_branch_exists? - reasons << "target branch is missing" unless merge_request.target_branch_exists? - reasons << "has merge conflicts" unless merge_request.project.repository.can_be_merged?(merge_request.diff_head_sha, merge_request.target_branch) - reasons - end - end - def cancel_merge_when_pipeline_succeeds_path if can_cancel_merge_when_pipeline_succeeds?(current_user) cancel_merge_when_pipeline_succeeds_project_merge_request_path(project, merge_request) @@ -179,6 +168,29 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated .can_push_to_branch?(source_branch) end + def can_remove_source_branch? + source_branch_exists? && merge_request.can_remove_source_branch?(current_user) + end + + def mergeable_discussions_state + # This avoids calling MergeRequest#mergeable_discussions_state without + # considering the state of the MR first. If a MR isn't mergeable, we can + # safely short-circuit it. + if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true) + merge_request.mergeable_discussions_state? + else + false + end + end + + def web_url + Gitlab::UrlBuilder.build(merge_request) + end + + def subscribed? + merge_request.subscribed?(current_user, merge_request.target_project) + end + private def cached_can_be_reverted? diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index ad655a7b3f4..d4d622d84ab 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -27,6 +27,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def statistics_buttons(show_auto_devops_callout:) [ + readme_anchor_data, changelog_anchor_data, license_anchor_data, contribution_guide_anchor_data, @@ -212,11 +213,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def readme_anchor_data - if current_user && can_current_user_push_to_default_branch? && repository.readme.blank? + if current_user && can_current_user_push_to_default_branch? && repository.readme.nil? OpenStruct.new(enabled: false, label: _('Add Readme'), link: add_readme_path) - elsif repository.readme.present? + elsif repository.readme OpenStruct.new(enabled: true, label: _('Readme'), link: default_view != 'readme' ? readme_path : '#readme') diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb index ad039a2623d..b501fd5e964 100644 --- a/app/serializers/blob_entity.rb +++ b/app/serializers/blob_entity.rb @@ -3,11 +3,13 @@ class BlobEntity < Grape::Entity expose :id, :path, :name, :mode + expose :readable_text?, as: :readable_text + expose :icon do |blob| IconsHelper.file_type_icon_class('file', blob.mode, blob.name) end - expose :url do |blob| + expose :url, if: -> (*) { request.respond_to?(:ref) } do |blob| project_blob_path(request.project, File.join(request.ref, blob.path)) end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index ca4480fe2b1..2de9624aed4 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -35,7 +35,7 @@ class BuildDetailsEntity < JobEntity def build_failed_issue_options { title: "Job Failed ##{build.id}", - description: "Job [##{build.id}](#{project_job_path(project, build)}) failed for #{build.sha}:\n" } + description: "Job [##{build.id}](#{project_job_url(project, build)}) failed for #{build.sha}:\n" } end def current_user diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 6e68d275047..aa289a96975 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -1,25 +1,46 @@ class DiffFileEntity < Grape::Entity + include RequestAwareEntity + include BlobHelper + include CommitsHelper include DiffHelper include SubmoduleHelper include BlobHelper include IconsHelper - include ActionView::Helpers::TagHelper + include TreeHelper + include ChecksCollaboration + include Gitlab::Utils::StrongMemoize expose :submodule?, as: :submodule expose :submodule_link do |diff_file| - submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first + memoized_submodule_links(diff_file).first + end + + expose :submodule_tree_url do |diff_file| + memoized_submodule_links(diff_file).last end - expose :blob_path do |diff_file| - diff_file.blob.path + expose :blob, using: BlobEntity + + expose :can_modify_blob do |diff_file| + merge_request = options[:merge_request] + + if merge_request&.source_project && current_user + can_modify_blob?(diff_file.blob, merge_request.source_project, merge_request.source_branch) + else + false + end end - expose :blob_icon do |diff_file| - blob_icon(diff_file.b_mode, diff_file.file_path) + expose :file_hash do |diff_file| + Digest::SHA1.hexdigest(diff_file.file_path) end expose :file_path + expose :too_large?, as: :too_large + expose :collapsed?, as: :collapsed + expose :new_file?, as: :new_file + expose :deleted_file?, as: :deleted_file expose :renamed_file?, as: :renamed_file expose :old_path @@ -28,6 +49,36 @@ class DiffFileEntity < Grape::Entity expose :a_mode expose :b_mode expose :text?, as: :text + expose :added_lines + expose :removed_lines + expose :diff_refs + expose :content_sha + expose :stored_externally?, as: :stored_externally + expose :external_storage + + expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file| + merge_request = options[:merge_request] + project = merge_request.target_project + + next unless project + + diff_for_path_namespace_project_merge_request_path( + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: merge_request.iid, + old_path: diff_file.old_path, + new_path: diff_file.new_path, + file_identifier: diff_file.file_identifier + ) + end + + expose :formatted_external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].formatted_external_url + end + + expose :external_url, if: -> (_, options) { options[:environment] } do |diff_file| + options[:environment].external_url_for(diff_file.new_path, diff_file.content_sha) + end expose :old_path_html do |diff_file| old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) @@ -38,4 +89,64 @@ class DiffFileEntity < Grape::Entity _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) new_path end + + expose :edit_path, if: -> (_, options) { options[:merge_request] } do |diff_file| + merge_request = options[:merge_request] + + options = merge_request.persisted? ? { from_merge_request_iid: merge_request.iid } : {} + + next unless merge_request.source_project + + project_edit_blob_path(merge_request.source_project, + tree_join(merge_request.source_branch, diff_file.new_path), + options) + end + + expose :view_path, if: -> (_, options) { options[:merge_request] } do |diff_file| + merge_request = options[:merge_request] + + project = merge_request.target_project + + next unless project + + project_blob_path(project, tree_join(diff_file.content_sha, diff_file.new_path)) + end + + expose :replaced_view_path, if: -> (_, options) { options[:merge_request] } do |diff_file| + image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image' + image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha + + merge_request = options[:merge_request] + project = merge_request.target_project + + next unless project + + project_blob_path(project, tree_join(diff_file.old_content_sha, diff_file.old_path)) if image_diff && image_replaced + end + + expose :context_lines_path, if: -> (diff_file, _) { diff_file.text? } do |diff_file| + project_blob_diff_path(diff_file.repository.project, tree_join(diff_file.content_sha, diff_file.file_path)) + end + + # Used for inline diffs + expose :highlighted_diff_lines, if: -> (diff_file, _) { diff_file.text? } do |diff_file| + diff_file.diff_lines_for_serializer + end + + # Used for parallel diffs + expose :parallel_diff_lines, if: -> (diff_file, _) { diff_file.text? } + + def current_user + request.current_user + end + + def memoized_submodule_links(diff_file) + strong_memoize(:submodule_links) do + if diff_file.submodule? + submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository) + else + [] + end + end + end end diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb new file mode 100644 index 00000000000..bb804e5347a --- /dev/null +++ b/app/serializers/diffs_entity.rb @@ -0,0 +1,65 @@ +class DiffsEntity < Grape::Entity + include DiffHelper + include RequestAwareEntity + + expose :real_size + expose :size + + expose :branch_name do |diffs| + merge_request&.source_branch + end + + expose :target_branch_name do |diffs| + merge_request&.target_branch + end + + expose :commit do |diffs| + options[:commit] + end + + expose :merge_request_diff, using: MergeRequestDiffEntity do |diffs| + options[:merge_request_diff] + end + + expose :start_version, using: MergeRequestDiffEntity do |diffs| + options[:start_version] + end + + expose :latest_diff do |diffs| + options[:latest_diff] + end + + expose :latest_version_path, if: -> (*) { merge_request } do |diffs| + diffs_project_merge_request_path(merge_request&.project, merge_request) + end + + expose :added_lines do |diffs| + diffs.diff_files.sum(&:added_lines) + end + + expose :removed_lines do |diffs| + diffs.diff_files.sum(&:removed_lines) + end + + expose :render_overflow_warning do |diffs| + render_overflow_warning?(diffs.diff_files) + end + + expose :email_patch_path, if: -> (*) { merge_request } do |diffs| + merge_request_path(merge_request, format: :patch) + end + + expose :plain_diff_path, if: -> (*) { merge_request } do |diffs| + merge_request_path(merge_request, format: :diff) + end + + expose :diff_files, using: DiffFileEntity + + expose :merge_request_diffs, using: MergeRequestDiffEntity, if: -> (_, options) { options[:merge_request_diffs]&.any? } do |diffs| + options[:merge_request_diffs] + end + + def merge_request + options[:merge_request] + end +end diff --git a/app/serializers/diffs_serializer.rb b/app/serializers/diffs_serializer.rb new file mode 100644 index 00000000000..6771e10c5ac --- /dev/null +++ b/app/serializers/diffs_serializer.rb @@ -0,0 +1,3 @@ +class DiffsSerializer < BaseSerializer + entity DiffsEntity +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index 718fb35e62d..63f28133a64 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -1,16 +1,31 @@ class DiscussionEntity < Grape::Entity include RequestAwareEntity + include NotesHelper expose :id, :reply_id + expose :position, if: -> (d, _) { d.diff_discussion? } + expose :line_code, if: -> (d, _) { d.diff_discussion? } expose :expanded?, as: :expanded + expose :active?, as: :active, if: -> (d, _) { d.diff_discussion? } + expose :project_id expose :notes do |discussion, opts| request.note_entity.represent(discussion.notes, opts) end + expose :discussion_path do |discussion| + discussion_path(discussion) + end + expose :individual_note?, as: :individual_note - expose :resolvable?, as: :resolvable + expose :resolvable do |discussion| + discussion.resolvable? + end + expose :resolved?, as: :resolved + expose :resolved_by_push?, as: :resolved_by_push + expose :resolved_by + expose :resolved_at expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion| resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id) end @@ -18,24 +33,17 @@ class DiscussionEntity < Grape::Entity new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) end - expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file } + expose :diff_file, using: DiffFileEntity, if: -> (d, _) { d.diff_discussion? } expose :diff_discussion?, as: :diff_discussion - expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion| - options[:context].render_to_string( - partial: "projects/diffs/line", - collection: discussion.truncated_diff_lines, - as: :line, - locals: { diff_file: discussion.diff_file, - discussion_expanded: true, - plain: true }, - layout: false, - formats: [:html] - ) + expose :truncated_diff_lines_path, if: -> (d, _) { !d.expanded? && !render_truncated_diff_lines? } do |discussion| + project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion) end - expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion| + expose :truncated_diff_lines, if: -> (d, _) { d.diff_discussion? && d.on_text? && (d.expanded? || render_truncated_diff_lines?) } + + expose :image_diff_html, if: -> (d, _) { d.diff_discussion? && d.on_image? } do |discussion| diff_file = discussion.diff_file partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff' options[:context].render_to_string( @@ -47,4 +55,17 @@ class DiscussionEntity < Grape::Entity formats: [:html] ) end + + expose :for_commit?, as: :for_commit + expose :commit_id + + private + + def render_truncated_diff_lines? + options[:render_truncated_diff_lines] + end + + def current_user + request.current_user + end end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index e4aec977f01..1c06691026d 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -5,4 +5,8 @@ class MergeRequestBasicEntity < IssuableSidebarEntity expose :state expose :source_branch_exists?, as: :source_branch_exists expose :rebase_in_progress?, as: :rebase_in_progress + expose :milestone, using: API::Entities::Milestone + expose :labels, using: LabelEntity + expose :assignee, using: API::Entities::UserBasic + expose :task_status, :task_status_short end diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb new file mode 100644 index 00000000000..32c761b45ac --- /dev/null +++ b/app/serializers/merge_request_diff_entity.rb @@ -0,0 +1,46 @@ +class MergeRequestDiffEntity < Grape::Entity + include Gitlab::Routing + include GitHelper + include MergeRequestsHelper + + expose :version_index do |merge_request_diff| + @merge_request_diffs = options[:merge_request_diffs] + diff = options[:merge_request_diff] + + next unless diff.present? + next unless @merge_request_diffs.size > 1 + + version_index(merge_request_diff) + end + + expose :created_at + expose :commits_count + + expose :latest?, as: :latest + + expose :short_commit_sha do |merge_request_diff| + short_sha(merge_request_diff.head_commit_sha) + end + + expose :version_path do |merge_request_diff| + start_sha = options[:start_sha] + project = merge_request.target_project + + next unless project + + merge_request_version_path(project, merge_request, merge_request_diff, start_sha) + end + + expose :compare_path do |merge_request_diff| + project = merge_request.target_project + diff = options[:merge_request_diff] + + if project && diff + merge_request_version_path(project, merge_request, diff, merge_request_diff.head_commit_sha) + end + end + + def merge_request + options[:merge_request] + end +end diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb new file mode 100644 index 00000000000..33fc7b724d5 --- /dev/null +++ b/app/serializers/merge_request_user_entity.rb @@ -0,0 +1,24 @@ +class MergeRequestUserEntity < UserEntity + include RequestAwareEntity + include BlobHelper + include TreeHelper + + expose :can_fork do |user| + can?(user, :fork_project, request.project) if project + end + + expose :can_create_merge_request do |user| + project && can?(user, :create_merge_request_in, project) + end + + expose :fork_path, if: -> (*) { project } do |user| + params = edit_blob_fork_params("Edit") + project_forks_path(project, namespace_key: user.namespace.id, continue: params) + end + + def project + return false unless request.respond_to?(:project) && request.project + + request.project + end +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 141070aef45..5d72ebdd7fd 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -13,7 +13,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :squash expose :target_branch expose :target_project_id - expose :allow_maintainer_to_push + expose :allow_collaboration expose :should_be_rebased?, as: :should_be_rebased expose :ff_only_enabled do |merge_request| @@ -109,7 +109,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :current_user do expose :can_remove_source_branch do |merge_request| - merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user) + presenter(merge_request).can_remove_source_branch? end expose :can_revert_on_current_merge_request do |merge_request| @@ -120,12 +120,12 @@ class MergeRequestWidgetEntity < IssuableEntity presenter(merge_request).can_cherry_pick_on_current_merge_request? end - expose :can_create_note do |issue| - can?(request.current_user, :create_note, issue.project) + expose :can_create_note do |merge_request| + can?(request.current_user, :create_note, merge_request) end - expose :can_update do |issue| - can?(request.current_user, :update_issue, issue) + expose :can_update do |merge_request| + can?(request.current_user, :update_merge_request, merge_request) end end @@ -209,6 +209,10 @@ class MergeRequestWidgetEntity < IssuableEntity commit_change_content_project_merge_request_path(merge_request.project, merge_request) end + expose :preview_note_path do |merge_request| + preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.id) + end + expose :merge_commit_path do |merge_request| if merge_request.merge_commit_sha project_commit_path(merge_request.project, merge_request.merge_commit_sha) diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 06d603b277e..ce0c31b5806 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -1,5 +1,6 @@ class NoteEntity < API::Entities::Note include RequestAwareEntity + include NotesHelper expose :type @@ -15,16 +16,21 @@ class NoteEntity < API::Entities::Note expose :current_user do expose :can_edit do |note| - Ability.allowed?(request.current_user, :admin_note, note) + can?(current_user, :admin_note, note) end expose :can_award_emoji do |note| - Ability.allowed?(request.current_user, :award_emoji, note) + can?(current_user, :award_emoji, note) + end + + expose :can_resolve do |note| + note.resolvable? && can?(current_user, :resolve_note, note) end end expose :resolved?, as: :resolved expose :resolvable?, as: :resolvable + expose :resolved_by, using: NoteUserEntity expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| @@ -42,5 +48,23 @@ class NoteEntity < API::Entities::Note new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) end + expose :noteable_note_url do |note| + noteable_note_url(note) + end + + expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) + end + + expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) + end + expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } + + private + + def current_user + request.current_user + end end diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index 130968a44c1..8ba9cac53c4 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -1,6 +1,6 @@ class PipelineDetailsEntity < PipelineEntity expose :details do - expose :legacy_stages, as: :stages, using: StageEntity + expose :ordered_stages, as: :stages, using: StageEntity expose :artifacts, using: BuildArtifactEntity expose :manual_actions, using: BuildActionEntity end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 7181f8a6b04..17a022539bb 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -1,14 +1,11 @@ class PipelineSerializer < BaseSerializer include WithPagination - - InvalidResourceError = Class.new(StandardError) - entity PipelineDetailsEntity def represent(resource, opts = {}) if resource.is_a?(ActiveRecord::Relation) - resource = resource.preload([ + :stages, :retryable_builds, :cancelable_statuses, :trigger_requests, @@ -20,10 +17,14 @@ class PipelineSerializer < BaseSerializer end if paginated? - super(@paginator.paginate(resource), opts) - else - super(resource, opts) + resource = paginator.paginate(resource) end + + if opts.delete(:preload) + resource = Gitlab::Ci::Pipeline::Preloader.preload!(resource) + end + + super(resource, opts) end def represent_status(resource) @@ -36,7 +37,7 @@ class PipelineSerializer < BaseSerializer def represent_stages(resource) return {} unless resource.present? - data = represent(resource, { only: [{ details: [:stages] }] }) + data = represent(resource, { only: [{ details: [:stages] }], preload: true }) data.dig(:details, :stages) || [] end end diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index 8e8bda2f9df..47df7f9dcf9 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -7,16 +7,7 @@ class StatusEntity < Grape::Entity expose :details_path expose :favicon do |status| - dir = - if Gitlab::Utils.to_boolean(ENV['CANARY']) - File.join('ci_favicons', 'canary') - elsif Rails.env.development? - File.join('ci_favicons', 'dev') - else - 'ci_favicons' - end - - ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) + Gitlab::Favicon.status_overlay(status.favicon) end expose :action, if: -> (status, _) { status.has_action? } do diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index e70445cfb67..7bcb8f49d0d 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -1,5 +1,7 @@ module ApplicationSettings class UpdateService < ApplicationSettings::BaseService + attr_reader :params, :application_setting + def execute update_terms(@params.delete(:terms)) diff --git a/app/services/applications/create_service.rb b/app/services/applications/create_service.rb index 35d45f25a71..94a434b95dd 100644 --- a/app/services/applications/create_service.rb +++ b/app/services/applications/create_service.rb @@ -2,11 +2,10 @@ module Applications class CreateService def initialize(current_user, params) @current_user = current_user - @params = params - @ip_address = @params.delete(:ip_address) + @params = params.except(:ip_address) end - def execute(request = nil) + def execute(request) Doorkeeper::Application.create(@params) end end diff --git a/app/services/base_count_service.rb b/app/services/base_count_service.rb index f2844854112..975e288301c 100644 --- a/app/services/base_count_service.rb +++ b/app/services/base_count_service.rb @@ -17,7 +17,7 @@ class BaseCountService end def refresh_cache(&block) - Rails.cache.write(cache_key, block_given? ? yield : uncached_count, raw: raw?) + update_cache_for_key(cache_key, &block) end def uncached_count @@ -41,4 +41,8 @@ class BaseCountService def cache_options { raw: raw? } end + + def update_cache_for_key(key, &block) + Rails.cache.write(key, block_given? ? yield : uncached_count, raw: raw?) + end end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index 6883ba36c71..3519b7c5e7d 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -3,7 +3,7 @@ class BaseService attr_accessor :project, :current_user, :params - def initialize(project, user, params = {}) + def initialize(project, user = nil, params = {}) @project, @current_user, @params = project, user, params.dup end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 5a961ac89e4..b1dbe73cdf7 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -3,13 +3,18 @@ module Boards class ListService < Boards::BaseService def execute issues = IssuesFinder.new(current_user, filter_params).execute - issues = without_board_labels(issues) unless movable_list? || closed_list? - issues = with_list_label(issues) if movable_list? + issues = filter(issues) issues.order_by_position_and_priority end private + def filter(issues) + issues = without_board_labels(issues) unless list&.movable? || list&.closed? + issues = with_list_label(issues) if list&.label? + issues + end + def board @board ||= parent.boards.find(params[:board_id]) end @@ -20,18 +25,6 @@ module Boards @list = board.lists.find(params[:id]) if params.key?(:id) end - def movable_list? - return @movable_list if defined?(@movable_list) - - @movable_list = list.present? && list.movable? - end - - def closed_list? - return @closed_list if defined?(@closed_list) - - @closed_list = list.present? && list.closed? - end - def filter_params set_parent set_state diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 3ceab209f3f..ee3112c7571 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -3,7 +3,7 @@ module Boards class MoveService < Boards::BaseService def execute(issue) return false unless can?(current_user, :update_issue, issue) - return false if issue_params.empty? + return false if issue_params(issue).empty? update(issue) end @@ -28,10 +28,10 @@ module Boards end def update(issue) - ::Issues::UpdateService.new(issue.project, current_user, issue_params).execute(issue) + ::Issues::UpdateService.new(issue.project, current_user, issue_params(issue)).execute(issue) end - def issue_params + def issue_params(issue) attrs = {} if move_between_lists? diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index 02f1c709374..6fd9885d4f3 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -1,16 +1,28 @@ module Boards module Lists class CreateService < Boards::BaseService + include Gitlab::Utils::StrongMemoize + def execute(board) List.transaction do - label = available_labels_for(board).find(params[:label_id]) + target = target(board) position = next_position(board) - create_list(board, label, position) + create_list(board, type, target, position) end end private + def type + :label + end + + def target(board) + strong_memoize(:target) do + available_labels_for(board).find(params[:label_id]) + end + end + def available_labels_for(board) options = { include_ancestor_groups: true } @@ -28,8 +40,8 @@ module Boards max_position.nil? ? 0 : max_position.succ end - def create_list(board, label, position) - board.lists.create(label: label, list_type: :label, position: position) + def create_list(board, type, target, position) + board.lists.create(type => target, list_type: type, position: position) end end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 925775aea0b..9bdbb2c0d99 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -25,14 +25,12 @@ module Ci valid = true - if Feature.enabled?('ci_job_request_with_tags_matcher') - # pick builds that does not have other tags than runner's one - builds = builds.matches_tag_ids(runner.tags.ids) + # pick builds that does not have other tags than runner's one + builds = builds.matches_tag_ids(runner.tags.ids) - # pick builds that have at least one tag - unless runner.run_untagged? - builds = builds.with_any_tags - end + # pick builds that have at least one tag + unless runner.run_untagged? + builds = builds.with_any_tags end builds.find do |build| diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index f96f2931508..4d0578becbe 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -17,7 +17,7 @@ module Commits new_commit = create_commit! success(result: new_commit) - rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::HooksService::PreReceiveError => ex + rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::PreReceiveError => ex error(ex.message) end diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb index 30be6accc32..f45436370c1 100644 --- a/app/services/concerns/exclusive_lease_guard.rb +++ b/app/services/concerns/exclusive_lease_guard.rb @@ -47,6 +47,6 @@ module ExclusiveLeaseGuard end def log_error(message, extra_args = {}) - logger.error(message) + Rails.logger.error(message) end end diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb index 26eb274f4d5..455f761ca9b 100644 --- a/app/services/concerns/issues/resolve_discussions.rb +++ b/app/services/concerns/issues/resolve_discussions.rb @@ -14,7 +14,6 @@ module Issues def merge_request_to_resolve_discussions_of strong_memoize(:merge_request_to_resolve_discussions_of) do MergeRequestsFinder.new(current_user, project_id: project.id) - .execute .find_by(iid: merge_request_to_resolve_discussions_of_iid) end end diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb index 0ba6a0ac6b5..9b1a4d960e2 100644 --- a/app/services/create_branch_service.rb +++ b/app/services/create_branch_service.rb @@ -14,7 +14,7 @@ class CreateBranchService < BaseService else error('Invalid reference name') end - rescue Gitlab::Git::HooksService::PreReceiveError => ex + rescue Gitlab::Git::PreReceiveError => ex error(ex.message) end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 1f059c31944..e1499dcee64 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -16,7 +16,7 @@ class DeleteBranchService < BaseService else error('Failed to remove branch') end - rescue Gitlab::Git::HooksService::PreReceiveError => ex + rescue Gitlab::Git::PreReceiveError => ex error(ex.message) end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 78e79344c99..6e5c29a5c40 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -58,7 +58,8 @@ module Issues def cloneable_label_ids params = { project_id: @new_project.id, - title: @old_issue.labels.pluck(:title) + title: @old_issue.labels.pluck(:title), + include_ancestor_groups: true } LabelsFinder.new(current_user, params).execute.pluck(:id) diff --git a/app/services/lfs/unlock_file_service.rb b/app/services/lfs/unlock_file_service.rb index 7eb89339a92..7e3edf21d54 100644 --- a/app/services/lfs/unlock_file_service.rb +++ b/app/services/lfs/unlock_file_service.rb @@ -24,7 +24,7 @@ module Lfs success(lock: lock, http_status: :ok) elsif forced - error(_('You must have master access to force delete a lock'), 403) + error(_('You must have maintainer access to force delete a lock'), 403) else error(_("%{lock_path} is locked by GitLab User %{lock_user_id}") % { lock_path: lock.path, lock_user_id: lock.user_id }, 403) end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 231ab76fde4..4c420b38258 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -38,8 +38,8 @@ module MergeRequests def filter_params(merge_request) super - unless merge_request.can_allow_maintainer_to_push?(current_user) - params.delete(:allow_maintainer_to_push) + unless merge_request.can_allow_collaboration?(current_user) + params.delete(:allow_collaboration) end end diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb index cf687b71d16..3407b312700 100644 --- a/app/services/merge_requests/create_from_issue_service.rb +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -41,7 +41,9 @@ module MergeRequests end def ref - @ref || project.default_branch || 'master' + return @ref if project.repository.branch_exists?(@ref) + + project.default_branch || 'master' end def merge_request diff --git a/app/services/merge_requests/delete_non_latest_diffs_service.rb b/app/services/merge_requests/delete_non_latest_diffs_service.rb new file mode 100644 index 00000000000..40079b21189 --- /dev/null +++ b/app/services/merge_requests/delete_non_latest_diffs_service.rb @@ -0,0 +1,18 @@ +module MergeRequests + class DeleteNonLatestDiffsService + BATCH_SIZE = 10 + + def initialize(merge_request) + @merge_request = merge_request + end + + def execute + diffs = @merge_request.non_latest_diffs.with_files + + diffs.each_batch(of: BATCH_SIZE) do |relation, index| + ids = relation.pluck(:id).map { |id| [id] } + DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids) + end + end + end +end diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index ba6853b835a..bffc09c34f0 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -13,7 +13,7 @@ module MergeRequests source, merge_request.target_branch, merge_request: merge_request) - rescue Gitlab::Git::HooksService::PreReceiveError => e + rescue Gitlab::Git::PreReceiveError => e raise MergeError, e.message rescue StandardError => e raise MergeError, "Something went wrong during merge: #{e.message}" diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb deleted file mode 100644 index 10aa9ae609c..00000000000 --- a/app/services/merge_requests/merge_request_diff_cache_service.rb +++ /dev/null @@ -1,17 +0,0 @@ -module MergeRequests - class MergeRequestDiffCacheService - def execute(merge_request, new_diff) - # Executing the iteration we cache all the highlighted diff information - merge_request.diffs.diff_files.to_a - - # Remove cache for all diffs on this MR. Do not use the association on the - # model, as that will interfere with other actions happening when - # reloading the diff. - MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff| - next if merge_request_diff == new_diff - - merge_request_diff.diffs.clear_cache! - end - end - end -end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 126da891c78..3d587f97906 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -79,7 +79,7 @@ module MergeRequests message = params[:commit_message] || merge_request.merge_commit_message repository.merge(current_user, source, merge_request, message) - rescue Gitlab::Git::HooksService::PreReceiveError => e + rescue Gitlab::Git::PreReceiveError => e handle_merge_error(log_message: e.message) raise MergeError, 'Something went wrong during merge pre-receive hook' rescue => e diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index c78e78afcd1..7606d68ff29 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -6,15 +6,16 @@ module MergeRequests # class PostMergeService < MergeRequests::BaseService def execute(merge_request) + merge_request.mark_as_merged close_issues(merge_request) todo_service.merge_merge_request(merge_request, current_user) - merge_request.mark_as_merged create_event(merge_request) create_note(merge_request) notification_service.merge_mr(merge_request, current_user) execute_hooks(merge_request, 'merge') invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches + delete_non_latest_diffs(merge_request) end private @@ -31,6 +32,10 @@ module MergeRequests end end + def delete_non_latest_diffs(merge_request) + DeleteNonLatestDiffsService.new(merge_request).execute + end + def create_merge_event(merge_request, current_user) EventCreateService.new.merge_mr(merge_request, current_user) end diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index c0083cd6afd..5b4bc86b9ba 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -18,10 +18,18 @@ module MergeRequests return false end + log_prefix = "#{self.class.name} info (#{merge_request.to_reference(full: true)}):" + + Gitlab::GitLogger.info("#{log_prefix} rebase started") + rebase_sha = repository.rebase(current_user, merge_request) + Gitlab::GitLogger.info("#{log_prefix} rebased to #{rebase_sha}") + merge_request.update_attributes(rebase_commit_sha: rebase_sha) + Gitlab::GitLogger.info("#{log_prefix} rebase SHA saved: #{rebase_sha}") + true rescue => e log_error(REBASE_ERROR, save_message_on_model: true) diff --git a/app/services/merge_requests/reload_diffs_service.rb b/app/services/merge_requests/reload_diffs_service.rb new file mode 100644 index 00000000000..2ec7b403903 --- /dev/null +++ b/app/services/merge_requests/reload_diffs_service.rb @@ -0,0 +1,43 @@ +module MergeRequests + class ReloadDiffsService + def initialize(merge_request, current_user) + @merge_request = merge_request + @current_user = current_user + end + + def execute + old_diff_refs = merge_request.diff_refs + new_diff = merge_request.create_merge_request_diff + + clear_cache(new_diff) + update_diff_discussion_positions(old_diff_refs) + end + + private + + attr_reader :merge_request, :current_user + + def update_diff_discussion_positions(old_diff_refs) + new_diff_refs = merge_request.diff_refs + + merge_request.update_diff_discussion_positions(old_diff_refs: old_diff_refs, + new_diff_refs: new_diff_refs, + current_user: current_user) + end + + def clear_cache(new_diff) + # Executing the iteration we cache highlighted diffs for each diff file of + # MergeRequestDiff. + new_diff.diffs_collection.diff_files.to_a + + # Remove cache for all diffs on this MR. Do not use the association on the + # model, as that will interfere with other actions happening when + # reloading the diff. + MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff| + next if merge_request_diff == new_diff + + merge_request_diff.diffs_collection.clear_cache! + end + end + end +end diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb index 236e9fe8c44..51ff9eff5e4 100644 --- a/app/services/metrics_service.rb +++ b/app/services/metrics_service.rb @@ -6,7 +6,8 @@ class MetricsService Gitlab::HealthChecks::Redis::RedisCheck, Gitlab::HealthChecks::Redis::CacheCheck, Gitlab::HealthChecks::Redis::QueuesCheck, - Gitlab::HealthChecks::Redis::SharedStateCheck + Gitlab::HealthChecks::Redis::SharedStateCheck, + Gitlab::HealthChecks::GitalyCheck ].freeze def prometheus_metrics_text diff --git a/app/services/pages_service.rb b/app/services/pages_service.rb deleted file mode 100644 index 446eeb34d3b..00000000000 --- a/app/services/pages_service.rb +++ /dev/null @@ -1,15 +0,0 @@ -class PagesService - attr_reader :data - - def initialize(data) - @data = data - end - - def execute - return unless Settings.pages.enabled - return unless data[:build_name] == 'pages' - return unless data[:build_status] == 'success' - - PagesWorker.perform_async(:deploy, data[:build_id]) - end -end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 346971138b1..aa60661f7f2 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -11,7 +11,7 @@ module Projects order: { due_date: :asc, title: :asc } } - finder_params[:group_ids] = [@project.group.id] if @project.group + finder_params[:group_ids] = @project.group.self_and_ancestors_ids if @project.group MilestonesFinder.new(finder_params).execute.select([:iid, :title]) end diff --git a/app/services/projects/count_service.rb b/app/services/projects/count_service.rb index 933829b557b..4c8e000928f 100644 --- a/app/services/projects/count_service.rb +++ b/app/services/projects/count_service.rb @@ -22,8 +22,10 @@ module Projects ) end - def cache_key - ['projects', 'count_service', VERSION, @project.id, cache_key_name] + def cache_key(key = nil) + cache_key = key || cache_key_name + + ['projects', 'count_service', VERSION, @project.id, cache_key] end def self.query(project_ids) diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index d16ecdb7b9b..a02a9052fb2 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -46,6 +46,9 @@ module Projects yield(@project) if block_given? + # If the block added errors, don't try to save the project + return @project if @project.errors.any? + @project.creator = current_user if forked_from_project_id @@ -63,6 +66,7 @@ module Projects message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} " fail(error: message) rescue => e + @project.errors.add(:base, e.message) if @project fail(error: e.message) end @@ -141,7 +145,6 @@ module Projects Rails.logger.error(log_message) if @project - @project.errors.add(:base, message) @project.mark_import_as_failed(message) if @project.persisted? && @project.import? end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index de0125ed0dd..02769e72229 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -135,6 +135,7 @@ module Projects raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.') end + log_destroy_event trash_repositories! # Rails attempts to load all related records into memory before @@ -148,6 +149,10 @@ module Projects end end + def log_destroy_event + log_info("Attempting to destroy #{project.full_path} (#{project.id})") + end + ## # This method makes sure that we correctly remove registry tags # for legacy image repository (when repository path equals project path). diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 00080717600..1781a01cbd4 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -17,6 +17,8 @@ module Projects def execute add_repository_to_project + download_lfs_objects + import_data success @@ -37,7 +39,7 @@ module Projects # We should skip the repository for a GitHub import or GitLab project import, # because these importers fetch the project repositories for us. - return if has_importer? && importer_class.try(:imports_repository?) + return if importer_imports_repository? if unknown_url? # In this case, we only want to import issues, not a repository. @@ -73,6 +75,27 @@ module Projects end end + def download_lfs_objects + # In this case, we only want to import issues + return if unknown_url? + + # If it has its own repository importer, it has to implements its own lfs import download + return if importer_imports_repository? + + return unless project.lfs_enabled? + + oids_to_download = Projects::LfsPointers::LfsImportService.new(project).execute + download_service = Projects::LfsPointers::LfsDownloadService.new(project) + + oids_to_download.each do |oid, link| + download_service.execute(oid, link) + end + rescue => e + # Right now, to avoid aborting the importing process, we silently fail + # if any exception raises. + Rails.logger.error("The Lfs import process failed. #{e.message}") + end + def import_data return unless has_importer? @@ -98,5 +121,9 @@ module Projects def unknown_url? project.import_url == Project::UNKNOWN_IMPORT_URL end + + def importer_imports_repository? + has_importer? && importer_class.try(:imports_repository?) + end end end diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb new file mode 100644 index 00000000000..d9fb74b090e --- /dev/null +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -0,0 +1,93 @@ +# This service lists the download link from a remote source based on the +# oids provided +module Projects + module LfsPointers + class LfsDownloadLinkListService < BaseService + DOWNLOAD_ACTION = 'download'.freeze + + DownloadLinksError = Class.new(StandardError) + DownloadLinkNotFound = Class.new(StandardError) + + attr_reader :remote_uri + + def initialize(project, remote_uri: nil) + super(project) + + @remote_uri = remote_uri + end + + # This method accepts two parameters: + # - oids: hash of oids to query. The structure is { lfs_file_oid => lfs_file_size } + # + # Returns a hash with the structure { lfs_file_oids => download_link } + def execute(oids) + return {} unless project&.lfs_enabled? && remote_uri && oids.present? + + get_download_links(oids) + end + + private + + def get_download_links(oids) + response = Gitlab::HTTP.post(remote_uri, + body: request_body(oids), + headers: headers) + + raise DownloadLinksError, response.message unless response.success? + + parse_response_links(response['objects']) + end + + def parse_response_links(objects_response) + objects_response.each_with_object({}) do |entry, link_list| + begin + oid = entry['oid'] + link = entry.dig('actions', DOWNLOAD_ACTION, 'href') + + raise DownloadLinkNotFound unless link + + link_list[oid] = add_credentials(link) + rescue DownloadLinkNotFound, URI::InvalidURIError + Rails.logger.error("Link for Lfs Object with oid #{oid} not found or invalid.") + end + end + end + + def request_body(oids) + { + operation: DOWNLOAD_ACTION, + objects: oids.map { |oid, size| { oid: oid, size: size } } + }.to_json + end + + def headers + { + 'Accept' => LfsRequest::CONTENT_TYPE, + 'Content-Type' => LfsRequest::CONTENT_TYPE + }.freeze + end + + def add_credentials(link) + uri = URI.parse(link) + + if should_add_credentials?(uri) + uri.user = remote_uri.user + uri.password = remote_uri.password + end + + uri.to_s + end + + # The download link can be a local url or an object storage url + # If the download link has the some host as the import url then + # we add the same credentials because we may need them + def should_add_credentials?(link_uri) + url_credentials? && link_uri.host == remote_uri.host + end + + def url_credentials? + remote_uri.user.present? || remote_uri.password.present? + end + end + end +end diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb new file mode 100644 index 00000000000..6ea43561d61 --- /dev/null +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -0,0 +1,58 @@ +# This service downloads and links lfs objects from a remote URL +module Projects + module LfsPointers + class LfsDownloadService < BaseService + def execute(oid, url) + return unless project&.lfs_enabled? && oid.present? && url.present? + + return if LfsObject.exists?(oid: oid) + + sanitized_uri = Gitlab::UrlSanitizer.new(url) + + with_tmp_file(oid) do |file| + size = download_and_save_file(file, sanitized_uri) + lfs_object = LfsObject.new(oid: oid, size: size, file: file) + + project.all_lfs_objects << lfs_object + end + rescue StandardError => e + Rails.logger.error("LFS file with oid #{oid} could't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}") + end + + private + + def download_and_save_file(file, sanitized_uri) + IO.copy_stream(open(sanitized_uri.sanitized_url, headers(sanitized_uri)), file) + end + + def headers(sanitized_uri) + {}.tap do |headers| + credentials = sanitized_uri.credentials + + if credentials[:user].present? || credentials[:password].present? + # Using authentication headers in the request + headers[:http_basic_authentication] = [credentials[:user], credentials[:password]] + end + end + end + + def with_tmp_file(oid) + create_tmp_storage_dir + + File.open(File.join(tmp_storage_dir, oid), 'w') { |file| yield file } + end + + def create_tmp_storage_dir + FileUtils.makedirs(tmp_storage_dir) unless Dir.exist?(tmp_storage_dir) + end + + def tmp_storage_dir + @tmp_storage_dir ||= File.join(storage_dir, 'tmp', 'download') + end + + def storage_dir + @storage_dir ||= Gitlab.config.lfs.storage_path + end + end + end +end diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb new file mode 100644 index 00000000000..b6b0dec142f --- /dev/null +++ b/app/services/projects/lfs_pointers/lfs_import_service.rb @@ -0,0 +1,92 @@ +# This service manages the whole worflow of discovering the Lfs files in a +# repository, linking them to the project and downloading (and linking) the non +# existent ones. +module Projects + module LfsPointers + class LfsImportService < BaseService + include Gitlab::Utils::StrongMemoize + + HEAD_REV = 'HEAD'.freeze + LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze + LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze + + LfsImportError = Class.new(StandardError) + + def execute + return {} unless project&.lfs_enabled? + + if external_lfs_endpoint? + # If the endpoint host is different from the import_url it means + # that the repo is using a third party service for storing the LFS files. + # In this case, we have to disable lfs in the project + disable_lfs! + + return {} + end + + get_download_links + rescue LfsDownloadLinkListService::DownloadLinksError => e + raise LfsImportError, "The LFS objects download list couldn't be imported. Error: #{e.message}" + end + + private + + def external_lfs_endpoint? + lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host + end + + def disable_lfs! + project.update(lfs_enabled: false) + end + + def get_download_links + existent_lfs = LfsListService.new(project).execute + linked_oids = LfsLinkService.new(project).execute(existent_lfs.keys) + + # Retrieving those oids not linked and which we need to download + not_linked_lfs = existent_lfs.except(*linked_oids) + + LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(not_linked_lfs) + end + + def lfsconfig_endpoint_uri + strong_memoize(:lfsconfig_endpoint_uri) do + # Retrieveing the blob data from the .lfsconfig file + data = project.repository.lfsconfig_for(HEAD_REV) + # Parsing the data to retrieve the url + parsed_data = data&.match(LFS_ENDPOINT_PATTERN) + + if parsed_data + URI.parse(parsed_data[1]).tap do |endpoint| + endpoint.user ||= import_uri.user + endpoint.password ||= import_uri.password + end + end + end + rescue URI::InvalidURIError + raise LfsImportError, 'Invalid URL in .lfsconfig file' + end + + def import_uri + @import_uri ||= URI.parse(project.import_url) + rescue URI::InvalidURIError + raise LfsImportError, 'Invalid project import URL' + end + + def current_endpoint_uri + (lfsconfig_endpoint_uri || default_endpoint_uri) + end + + # The import url must end with '.git' here we ensure it is + def default_endpoint_uri + @default_endpoint_uri ||= begin + import_uri.dup.tap do |uri| + path = uri.path.gsub(%r(/$), '') + path += '.git' unless path.ends_with?('.git') + uri.path = path + LFS_BATCH_API_ENDPOINT + end + end + end + end + end +end diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb new file mode 100644 index 00000000000..d20bdf86c58 --- /dev/null +++ b/app/services/projects/lfs_pointers/lfs_link_service.rb @@ -0,0 +1,29 @@ +# Given a list of oids, this services links the existent Lfs Objects to the project +module Projects + module LfsPointers + class LfsLinkService < BaseService + # Accept an array of oids to link + # + # Returns a hash with the same structure with oids linked + def execute(oids) + return {} unless project&.lfs_enabled? + + # Search and link existing LFS Object + link_existing_lfs_objects(oids) + end + + private + + def link_existing_lfs_objects(oids) + existent_lfs_objects = LfsObject.where(oid: oids) + + return [] unless existent_lfs_objects.any? + + not_linked_lfs_objects = existent_lfs_objects.where.not(id: project.all_lfs_objects) + project.all_lfs_objects << not_linked_lfs_objects + + existent_lfs_objects.pluck(:oid) + end + end + end +end diff --git a/app/services/projects/lfs_pointers/lfs_list_service.rb b/app/services/projects/lfs_pointers/lfs_list_service.rb new file mode 100644 index 00000000000..b770982cbc0 --- /dev/null +++ b/app/services/projects/lfs_pointers/lfs_list_service.rb @@ -0,0 +1,19 @@ +# This service list all existent Lfs objects in a repository +module Projects + module LfsPointers + class LfsListService < BaseService + REV = 'HEAD'.freeze + + # Retrieve all lfs blob pointers and returns a hash + # with the structure { lfs_file_oid => lfs_file_size } + def execute + return {} unless project&.lfs_enabled? + + Gitlab::Git::LfsChanges.new(project.repository, REV) + .all_pointers + .map! { |blob| [blob.lfs_oid, blob.lfs_size] } + .to_h + end + end + end +end diff --git a/app/services/projects/open_issues_count_service.rb b/app/services/projects/open_issues_count_service.rb index 0a004677417..78b1477186a 100644 --- a/app/services/projects/open_issues_count_service.rb +++ b/app/services/projects/open_issues_count_service.rb @@ -4,6 +4,10 @@ module Projects class OpenIssuesCountService < Projects::CountService include Gitlab::Utils::StrongMemoize + # Cache keys used to store issues count + PUBLIC_COUNT_KEY = 'public_open_issues_count'.freeze + TOTAL_COUNT_KEY = 'total_open_issues_count'.freeze + def initialize(project, user = nil) @user = user @@ -11,7 +15,7 @@ module Projects end def cache_key_name - public_only? ? 'public_open_issues_count' : 'total_open_issues_count' + public_only? ? PUBLIC_COUNT_KEY : TOTAL_COUNT_KEY end def public_only? @@ -28,6 +32,32 @@ module Projects end end + def public_count_cache_key + cache_key(PUBLIC_COUNT_KEY) + end + + def total_count_cache_key + cache_key(TOTAL_COUNT_KEY) + end + + def refresh_cache(&block) + if block_given? + super(&block) + else + count_grouped_by_confidential = self.class.query(@project, public_only: false).group(:confidential).count + public_count = count_grouped_by_confidential[false] || 0 + total_count = public_count + (count_grouped_by_confidential[true] || 0) + + update_cache_for_key(public_count_cache_key) do + public_count + end + + update_cache_for_key(total_count_cache_key) do + total_count + end + end + end + # We only show total issues count for reporters # which are allowed to view confidential issues # This will still show a discrepancy on issues number but should be less than before. diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 679f4a9cb62..d8250cd8102 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -17,6 +17,11 @@ module Projects ensure_wiki_exists if enabling_wiki? + yield if block_given? + + # If the block added errors, don't try to save the project + return validation_failed! if project.errors.any? + if project.update_attributes(params.except(:default_branch)) if project.previous_changes.include?('path') project.rename_repo @@ -28,21 +33,25 @@ module Projects success else - model_errors = project.errors.full_messages.to_sentence - error_message = model_errors.presence || 'Project could not be updated!' - - error(error_message) + validation_failed! end end def run_auto_devops_pipeline? - return false if project.repository.gitlab_ci_yml || !project.auto_devops.previous_changes.include?('enabled') + return false if project.repository.gitlab_ci_yml || !project.auto_devops&.previous_changes&.include?('enabled') project.auto_devops.enabled? || (project.auto_devops.enabled.nil? && Gitlab::CurrentSettings.auto_devops_enabled?) end private + def validation_failed! + model_errors = project.errors.full_messages.to_sentence + error_message = model_errors.presence || 'Project could not be updated!' + + error(error_message) + end + def renaming_project_with_container_registry_tags? new_path = params[:path] @@ -53,8 +62,8 @@ module Projects def changing_default_branch? new_branch = params[:default_branch] - project.repository.exists? && - new_branch && new_branch != project.default_branch + new_branch && project.repository.exists? && + new_branch != project.default_branch end def enabling_wiki? diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index 0215994b1a7..9ac8fdb4cff 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -561,6 +561,17 @@ module QuickActions end end + desc 'Make issue confidential.' + explanation do + 'Makes this issue confidential' + end + condition do + issuable.is_a?(Issue) && current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) + end + command :confidential do + @updates[:confidential] = true + end + def extract_users(params) return [] if params.nil? diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb index cc76d0df3a1..3cc88d77ba1 100644 --- a/app/services/tags/create_service.rb +++ b/app/services/tags/create_service.rb @@ -13,7 +13,7 @@ module Tags new_tag = repository.add_tag(current_user, tag_name, target, message) rescue Gitlab::Git::Repository::TagExistsError return error("Tag #{tag_name} already exists") - rescue Gitlab::Git::HooksService::PreReceiveError => ex + rescue Gitlab::Git::PreReceiveError => ex return error(ex.message) end diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index d3d46064729..b84b061e460 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -21,7 +21,7 @@ module Tags else error('Failed to remove tag') end - rescue Gitlab::Git::HooksService::PreReceiveError => ex + rescue Gitlab::Git::PreReceiveError => ex error(ex.message) end diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index 01d5d774cd5..65183e84cce 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -1,11 +1,13 @@ module TestHooks class ProjectService < TestHooks::BaseService - private + attr_writer :project def project @project ||= hook.project end + private + def push_events_data throw(:validation_error, 'Ensure the project has at least one commit.') if project.empty_repo? diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb index 7d1ed768ee8..643f2ce1481 100644 --- a/app/services/validate_new_branch_service.rb +++ b/app/services/validate_new_branch_service.rb @@ -13,7 +13,7 @@ class ValidateNewBranchService < BaseService end success - rescue Gitlab::Git::HooksService::PreReceiveError => ex + rescue Gitlab::Git::PreReceiveError => ex error(ex.message) end end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 7ec52b6ce2b..8a86e47f0ea 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -82,7 +82,7 @@ class WebHookService post_url = hook.url.gsub("#{parsed_url.userinfo}@", '') basic_auth = { username: CGI.unescape(parsed_url.user), - password: CGI.unescape(parsed_url.password) + password: CGI.unescape(parsed_url.password.presence || '') } make_request(post_url, basic_auth) end diff --git a/app/uploaders/favicon_uploader.rb b/app/uploaders/favicon_uploader.rb new file mode 100644 index 00000000000..3639375d474 --- /dev/null +++ b/app/uploaders/favicon_uploader.rb @@ -0,0 +1,13 @@ +class FaviconUploader < AttachmentUploader + EXTENSION_WHITELIST = %w[png ico].freeze + + def extension_whitelist + EXTENSION_WHITELIST + end + + private + + def filename_for_different_format(filename, format) + filename.chomp(File.extname(filename)) + ".#{format}" + end +end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 133fdf6684d..36bc0a4575a 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -65,10 +65,10 @@ class FileUploader < GitlabUploader SecureRandom.hex end - def upload_paths(filename) + def upload_paths(identifier) [ - File.join(secret, filename), - File.join(base_dir(Store::REMOTE), secret, filename) + File.join(secret, identifier), + File.join(base_dir(Store::REMOTE), secret, identifier) ] end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 5bdca26a584..b8ecfc4ee2b 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -10,8 +10,17 @@ module ObjectStorage UnknownStoreError = Class.new(StandardError) ObjectStorageUnavailable = Class.new(StandardError) - DIRECT_UPLOAD_TIMEOUT = 4.hours - DIRECT_UPLOAD_EXPIRE_OFFSET = 15.minutes + class ExclusiveLeaseTaken < StandardError + def initialize(lease_key) + @lease_key = lease_key + end + + def message + *lease_key_group, _ = *@lease_key.split(":") + "Exclusive lease for #{lease_key_group.join(':')} is already taken." + end + end + TMP_UPLOAD_PATH = 'tmp/uploads'.freeze module Store @@ -24,18 +33,18 @@ module ObjectStorage module RecordsUploads extend ActiveSupport::Concern - def prepended(base) + prepended do |base| raise "#{base} must include ObjectStorage::Concern to use extensions." unless base < Concern - base.include(RecordsUploads::Concern) + base.include(::RecordsUploads::Concern) end def retrieve_from_store!(identifier) - paths = store_dirs.map { |store, path| File.join(path, identifier) } + paths = upload_paths(identifier) unless current_upload_satisfies?(paths, model) # the upload we already have isn't right, find the correct one - self.upload = uploads.find_by(model: model, path: paths) + self.upload = model&.retrieve_upload(identifier, paths) end super @@ -48,7 +57,7 @@ module ObjectStorage end def upload=(upload) - return unless upload + return if upload.nil? self.object_store = upload.store super @@ -64,6 +73,15 @@ module ObjectStorage upload.id) end + def exclusive_lease_key + # For FileUploaders, model may have many uploaders. In that case + # we want to use exclusive key per upload, not per model to allow + # parallel migration + key_object = upload || model + + "object_storage_migrate:#{key_object.class}:#{key_object.id}" + end + private def current_upload_satisfies?(paths, model) @@ -157,9 +175,9 @@ module ObjectStorage model_class.uploader_options.dig(mount_point, :mount_on) || mount_point end - def workhorse_authorize + def workhorse_authorize(has_length:, maximum_size: nil) { - RemoteObject: workhorse_remote_upload_options, + RemoteObject: workhorse_remote_upload_options(has_length: has_length, maximum_size: maximum_size), TempPath: workhorse_local_upload_path }.compact end @@ -168,23 +186,16 @@ module ObjectStorage File.join(self.root, TMP_UPLOAD_PATH) end - def workhorse_remote_upload_options + def workhorse_remote_upload_options(has_length:, maximum_size: nil) return unless self.object_store_enabled? return unless self.direct_upload_enabled? id = [CarrierWave.generate_cache_id, SecureRandom.hex].join('-') upload_path = File.join(TMP_UPLOAD_PATH, id) - connection = ::Fog::Storage.new(self.object_store_credentials) - expire_at = Time.now + DIRECT_UPLOAD_TIMEOUT + DIRECT_UPLOAD_EXPIRE_OFFSET - options = { 'Content-Type' => 'application/octet-stream' } + direct_upload = ObjectStorage::DirectUpload.new(self.object_store_credentials, remote_store_path, upload_path, + has_length: has_length, maximum_size: maximum_size) - { - ID: id, - Timeout: DIRECT_UPLOAD_TIMEOUT, - GetURL: connection.get_object_url(remote_store_path, upload_path, expire_at), - DeleteURL: connection.delete_object_url(remote_store_path, upload_path, expire_at), - StoreURL: connection.put_object_url(remote_store_path, upload_path, expire_at, options) - } + direct_upload.to_hash.merge(ID: id) end end @@ -270,7 +281,7 @@ module ObjectStorage end def delete_migrated_file(migrated_file) - migrated_file.delete if exists? + migrated_file.delete end def exists? @@ -288,6 +299,13 @@ module ObjectStorage } end + # Returns all the possible paths for an upload. + # the `upload.path` is a lookup parameter, and it may change + # depending on the `store` param. + def upload_paths(identifier) + store_dirs.map { |store, path| File.join(path, identifier) } + end + def cache!(new_file = sanitized_file) # We intercept ::UploadedFile which might be stored on remote storage # We use that for "accelerated" uploads, where we store result on remote storage @@ -307,6 +325,10 @@ module ObjectStorage super end + def exclusive_lease_key + "object_storage_migrate:#{model.class}:#{model.id}" + end + private def schedule_background_upload? @@ -373,17 +395,14 @@ module ObjectStorage end end - def exclusive_lease_key - "object_storage_migrate:#{model.class}:#{model.id}" - end - def with_exclusive_lease - uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain - raise 'exclusive lease already taken' unless uuid + lease_key = exclusive_lease_key + uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain + raise ExclusiveLeaseTaken.new(lease_key) unless uuid yield uuid ensure - Gitlab::ExclusiveLease.cancel(exclusive_lease_key, uuid) + Gitlab::ExclusiveLease.cancel(lease_key, uuid) end # diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 89c74a78835..301f4681fcd 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -22,7 +22,7 @@ module RecordsUploads Upload.transaction do uploads.where(path: upload_path).delete_all - upload.destroy! if upload + upload.delete if upload self.upload = build_upload.tap(&:save!) end diff --git a/app/uploaders/uploader_helper.rb b/app/uploaders/uploader_helper.rb index fd446d31092..207928b61d0 100644 --- a/app/uploaders/uploader_helper.rb +++ b/app/uploaders/uploader_helper.rb @@ -1,6 +1,6 @@ # Extra methods for uploader module UploaderHelper - IMAGE_EXT = %w[png jpg jpeg gif bmp tiff].freeze + IMAGE_EXT = %w[png jpg jpeg gif bmp tiff ico].freeze # We recommend using the .mp4 format over .mov. Videos in .mov format can # still be used but you really need to make sure they are served with the # proper MIME type video/mp4 and not video/quicktime or your videos won't play diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 8648c4c75e3..6854fec582e 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -18,6 +18,13 @@ # This validator can also block urls pointing to localhost or the local network to # protect against Server-side Request Forgery (SSRF), or check for the right port. # +# The available options are: +# - protocols: Allowed protocols. Default: http and https +# - allow_localhost: Allow urls pointing to localhost. Default: true +# - allow_local_network: Allow urls pointing to private network addresses. Default: true +# - ports: Allowed ports. Default: all. +# - enforce_user: Validate user format. Default: false +# # Example: # class User < ActiveRecord::Base # validates :personal_url, url: { allow_localhost: false, allow_local_network: false} @@ -35,7 +42,7 @@ class UrlValidator < ActiveModel::EachValidator if value.present? value.strip! else - record.errors.add(attribute, "must be a valid URL") + record.errors.add(attribute, 'must be a valid URL') end Gitlab::UrlBlocker.validate!(value, blocker_args) @@ -51,7 +58,8 @@ class UrlValidator < ActiveModel::EachValidator protocols: DEFAULT_PROTOCOLS, ports: [], allow_localhost: true, - allow_local_network: true + allow_local_network: true, + enforce_user: false } end @@ -64,7 +72,7 @@ class UrlValidator < ActiveModel::EachValidator end def blocker_args - current_options.slice(:allow_localhost, :allow_local_network, :protocols, :ports).tap do |args| + current_options.slice(*default_options.keys).tap do |args| if allow_setting_local_requests? args[:allow_localhost] = args[:allow_local_network] = true end diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index 5e08837255f..a0861870ba4 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -11,13 +11,32 @@ = image_tag @appearance.header_logo_url, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br - = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-logo" + = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" %hr = f.hidden_field :header_logo_cache = f.file_field :header_logo, class: "" .hint Maximum file size is 1MB. Pages are optimized for a 28px tall header logo + %fieldset.app_logo + %legend + Favicon: + .form-group.row + = f.label :favicon, 'Favicon', class: 'col-sm-2 col-form-label' + .col-sm-10 + - if @appearance.favicon? + = image_tag @appearance.favicon_url, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" + %hr + = f.hidden_field :favicon_cache + = f.file_field :favicon, class: '' + .hint + Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}. + %br + Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior. + %fieldset.sign-in %legend Sign in/Sign up pages: @@ -38,7 +57,7 @@ = image_tag @appearance.logo_url, class: 'appearance-logo-preview' - if @appearance.persisted? %br - = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-logo" + = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" %hr = f.hidden_field :logo_cache = f.file_field :logo, class: "" diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml index 6b9b2a17dd9..91993838fc8 100644 --- a/app/views/admin/application_settings/_abuse.html.haml +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -2,11 +2,10 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :admin_notification_email, 'Abuse reports notification email', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :admin_notification_email, class: 'form-control' - .form-text.text-muted - Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. + .form-group + = f.label :admin_notification_email, 'Abuse reports notification email', class: 'label-light' + = f.text_field :admin_notification_email, class: 'form-control' + .form-text.text-muted + Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 07f9ea0865b..f40939747f4 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -2,38 +2,32 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :gravatar_enabled, class: 'form-check-input' - = f.label :gravatar_enabled, class: 'form-check-label' do - Gravatar enabled - .form-group.row - = f.label :default_projects_limit, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :default_projects_limit, class: 'form-control' - .form-group.row - = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :max_attachment_size, class: 'form-control' - .form-group.row - = f.label :session_expire_delay, 'Session duration (minutes)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :session_expire_delay, class: 'form-control' - %span.form-text.text-muted#session_expire_delay_help_block GitLab restart is required to apply changes - .form-group.row - = f.label :user_oauth_applications, 'User OAuth applications', class: 'col-form-label col-sm-2' - .col-sm-10 - .form-check - = f.check_box :user_oauth_applications, class: 'form-check-input' - = f.label :user_oauth_applications, class: 'form-check-label' do - Allow users to register any application to use GitLab as an OAuth provider - .form-group.row - = f.label :user_default_external, 'New users set to external', class: 'col-form-label col-sm-2' - .col-sm-10 - .form-check - = f.check_box :user_default_external, class: 'form-check-input' - = f.label :user_default_external, class: 'form-check-label' do - Newly registered users will by default be external + .form-group + .form-check + = f.check_box :gravatar_enabled, class: 'form-check-input' + = f.label :gravatar_enabled, class: 'form-check-label' do + Gravatar enabled + .form-group + = f.label :default_projects_limit, class: 'label-light' + = f.number_field :default_projects_limit, class: 'form-control' + .form-group + = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'label-light' + = f.number_field :max_attachment_size, class: 'form-control' + .form-group + = f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light' + = f.number_field :session_expire_delay, class: 'form-control' + %span.form-text.text-muted#session_expire_delay_help_block GitLab restart is required to apply changes + .form-group + = f.label :user_oauth_applications, 'User OAuth applications', class: 'label-light' + .form-check + = f.check_box :user_oauth_applications, class: 'form-check-input' + = f.label :user_oauth_applications, class: 'form-check-label' do + Allow users to register any application to use GitLab as an OAuth provider + .form-group + = f.label :user_default_external, 'New users set to external', class: 'label-light' + .form-check + = f.check_box :user_default_external, class: 'form-check-input' + = f.label :user_default_external, class: 'form-check-label' do + Newly registered users will by default be external = f.submit 'Save changes', class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_background_jobs.html.haml b/app/views/admin/application_settings/_background_jobs.html.haml index fc5df02242a..fd8e695ed49 100644 --- a/app/views/admin/application_settings/_background_jobs.html.haml +++ b/app/views/admin/application_settings/_background_jobs.html.haml @@ -6,25 +6,22 @@ These settings require a = link_to 'restart', help_page_path('administration/restart_gitlab') to take effect. - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :sidekiq_throttling_enabled, class: 'form-check-input' - = f.label :sidekiq_throttling_enabled, class: 'form-check-label' do - Enable Sidekiq Job Throttling - .form-text.text-muted - Limit the amount of resources slow running jobs are assigned. - .form-group.row - = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' } + .form-group + .form-check + = f.check_box :sidekiq_throttling_enabled, class: 'form-check-input' + = f.label :sidekiq_throttling_enabled, class: 'form-check-label' do + Enable Sidekiq Job Throttling .form-text.text-muted - Choose which queues you wish to throttle. - .form-group.row - = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01' - .form-text.text-muted - The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive. + Limit the amount of resources slow running jobs are assigned. + .form-group + = f.label :sidekiq_throttling_queues, 'Sidekiq queues to throttle', class: 'label-light' + = f.select :sidekiq_throttling_queues, sidekiq_queue_options_for_select, { include_hidden: false }, multiple: true, class: 'select2 select-wide', data: { field: 'sidekiq_throttling_queues' } + .form-text.text-muted + Choose which queues you wish to throttle. + .form-group + = f.label :sidekiq_throttling_factor, 'Throttling Factor', class: 'label-light' + = f.number_field :sidekiq_throttling_factor, class: 'form-control', min: '0.01', max: '0.99', step: '0.01' + .form-text.text-muted + The factor by which the queues should be throttled. A value between 0.0 and 1.0, exclusive. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 233821818e6..7c16cafe13f 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -2,46 +2,40 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :auto_devops_enabled, class: 'form-check-input' - = f.label :auto_devops_enabled, class: 'form-check-label' do - Enabled Auto DevOps for projects by default - .form-text.text-muted - It will automatically build, test, and deploy applications based on a predefined CI/CD configuration - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md') - .form-group.row - = f.label :auto_devops_domain, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' - .form-text.text-muted - = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :shared_runners_enabled, class: 'form-check-input' - = f.label :shared_runners_enabled, class: 'form-check-label' do - Enable shared runners for new projects - .form-group.row - = f.label :shared_runners_text, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_area :shared_runners_text, class: 'form-control', rows: 4 - .form-text.text-muted Markdown enabled - .form-group.row - = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :max_artifacts_size, class: 'form-control' - .form-text.text-muted - Set the maximum file size for each job's artifacts - = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size') - .form-group.row - = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :default_artifacts_expire_in, class: 'form-control' - .form-text.text-muted - Set the default expiration time for each job's artifacts. - 0 for unlimited. - = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') + .form-group + .form-check + = f.check_box :auto_devops_enabled, class: 'form-check-input' + = f.label :auto_devops_enabled, class: 'form-check-label' do + Enabled Auto DevOps for projects by default + .form-text.text-muted + It will automatically build, test, and deploy applications based on a predefined CI/CD configuration + = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md') + .form-group + = f.label :auto_devops_domain, class: 'label-light' + = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' + .form-text.text-muted + = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") + .form-group + .form-check + = f.check_box :shared_runners_enabled, class: 'form-check-input' + = f.label :shared_runners_enabled, class: 'form-check-label' do + Enable shared runners for new projects + .form-group + = f.label :shared_runners_text, class: 'label-light' + = f.text_area :shared_runners_text, class: 'form-control', rows: 4 + .form-text.text-muted Markdown enabled + .form-group + = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'label-light' + = f.number_field :max_artifacts_size, class: 'form-control' + .form-text.text-muted + Set the maximum file size for each job's artifacts + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size') + .form-group + = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'label-light' + = f.text_field :default_artifacts_expire_in, class: 'form-control' + .form-text.text-muted + Set the default expiration time for each job's artifacts. + 0 for unlimited. + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 01be5878a60..99e44ffa741 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -2,25 +2,23 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :email_author_in_body, class: 'form-check-input' - = f.label :email_author_in_body, class: 'form-check-label' do - Include author name in notification email body - .form-text.text-muted - Some email servers do not support overriding the email sender name. - Enable this option to include the name of the author of the issue, - merge request or comment in the email body instead. - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :html_emails_enabled, class: 'form-check-input' - = f.label :html_emails_enabled, class: 'form-check-label' do - Enable HTML emails - .form-text.text-muted - By default GitLab sends emails in HTML and plain text formats so mail - clients can choose what format to use. Disable this option if you only - want to send emails in plain text format. + .form-group + .form-check + = f.check_box :email_author_in_body, class: 'form-check-input' + = f.label :email_author_in_body, class: 'form-check-label' do + Include author name in notification email body + .form-text.text-muted + Some email servers do not support overriding the email sender name. + Enable this option to include the name of the author of the issue, + merge request or comment in the email body instead. + .form-group + .form-check + = f.check_box :html_emails_enabled, class: 'form-check-input' + = f.label :html_emails_enabled, class: 'form-check-label' do + Enable HTML emails + .form-text.text-muted + By default GitLab sends emails in HTML and plain text formats so mail + clients can choose what format to use. Disable this option if you only + want to send emails in plain text format. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index 859a1c6f45c..0b4001c0824 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -2,26 +2,23 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_default, class: 'form-control' - .form-text.text-muted - Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced - for git fetch/push operations or Sidekiq jobs. - .form-group.row - = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_fast, class: 'form-control' - .form-text.text-muted - Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. - If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' - can help maintain the stability of the GitLab instance. - .form-group.row - = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :gitaly_timeout_medium, class: 'form-control' - .form-text.text-muted - Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. + .form-group + = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'label-light' + = f.number_field :gitaly_timeout_default, class: 'form-control' + .form-text.text-muted + Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced + for git fetch/push operations or Sidekiq jobs. + .form-group + = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-light' + = f.number_field :gitaly_timeout_fast, class: 'form-control' + .form-text.text-muted + Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. + If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' + can help maintain the stability of the GitLab instance. + .form-group + = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'label-light' + = f.number_field :gitaly_timeout_medium, class: 'form-control' + .form-text.text-muted + Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml index 1f6c52d8b1a..1f402fcb786 100644 --- a/app/views/admin/application_settings/_help_page.html.haml +++ b/app/views/admin/application_settings/_help_page.html.haml @@ -2,21 +2,18 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :help_page_text, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_area :help_page_text, class: 'form-control', rows: 4 - .form-text.text-muted Markdown enabled - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :help_page_hide_commercial_content, class: 'form-check-input' - = f.label :help_page_hide_commercial_content, class: 'form-check-label' do - Hide marketing-related entries from help - .form-group.row - = f.label :help_page_support_url, 'Support page URL', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' - %span.form-text.text-muted#support_help_block Alternate support URL for help page + .form-group + = f.label :help_page_text, class: 'label-light' + = f.text_area :help_page_text, class: 'form-control', rows: 4 + .form-text.text-muted Markdown enabled + .form-group + .form-check + = f.check_box :help_page_hide_commercial_content, class: 'form-check-input' + = f.label :help_page_hide_commercial_content, class: 'form-check-label' do + Hide marketing-related entries from help + .form-group + = f.label :help_page_support_url, 'Support page URL', class: 'label-light' + = f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block' + %span.form-text.text-muted#support_help_block Alternate support URL for help page = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_influx.html.haml b/app/views/admin/application_settings/_influx.html.haml index b40a714ed8f..61e8e3199a9 100644 --- a/app/views/admin/application_settings/_influx.html.haml +++ b/app/views/admin/application_settings/_influx.html.haml @@ -8,61 +8,53 @@ = link_to 'restart', help_page_path('administration/restart_gitlab') to take effect. = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction') - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :metrics_enabled, class: 'form-check-input' - = f.label :metrics_enabled, class: 'form-check-label' do - Enable InfluxDB Metrics - .form-group.row - = f.label :metrics_host, 'InfluxDB host', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com' - .form-group.row - = f.label :metrics_port, 'InfluxDB port', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :metrics_port, class: 'form-control', placeholder: '8089' - .form-text.text-muted - The UDP port to use for connecting to InfluxDB. InfluxDB requires that - your server configuration specifies a database to store data in when - sending messages to this port, without it metrics data will not be - saved. - .form-group.row - = f.label :metrics_pool_size, 'Connection pool size', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_pool_size, class: 'form-control' - .form-text.text-muted - The amount of InfluxDB connections to open. Connections are opened - lazily. Users using multi-threaded application servers should ensure - enough connections are available (at minimum the amount of application - server threads). - .form-group.row - = f.label :metrics_timeout, 'Connection timeout', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_timeout, class: 'form-control' - .form-text.text-muted - The amount of seconds after which an InfluxDB connection will time - out. - .form-group.row - = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_method_call_threshold, class: 'form-control' - .form-text.text-muted - A method call is only tracked when it takes longer to complete than - the given amount of milliseconds. - .form-group.row - = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_sample_interval, class: 'form-control' - .form-text.text-muted - The sampling interval in seconds. Sampled data includes memory usage, - retained Ruby objects, file descriptors and so on. - .form-group.row - = f.label :metrics_packet_size, 'Metrics per packet', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :metrics_packet_size, class: 'form-control' - .form-text.text-muted - The amount of points to store in a single UDP packet. More points - results in fewer but larger UDP packets being sent. + .form-group + .form-check + = f.check_box :metrics_enabled, class: 'form-check-input' + = f.label :metrics_enabled, class: 'form-check-label' do + Enable InfluxDB Metrics + .form-group + = f.label :metrics_host, 'InfluxDB host', class: 'label-light' + = f.text_field :metrics_host, class: 'form-control', placeholder: 'influxdb.example.com' + .form-group + = f.label :metrics_port, 'InfluxDB port', class: 'label-light' + = f.text_field :metrics_port, class: 'form-control', placeholder: '8089' + .form-text.text-muted + The UDP port to use for connecting to InfluxDB. InfluxDB requires that + your server configuration specifies a database to store data in when + sending messages to this port, without it metrics data will not be + saved. + .form-group + = f.label :metrics_pool_size, 'Connection pool size', class: 'label-light' + = f.number_field :metrics_pool_size, class: 'form-control' + .form-text.text-muted + The amount of InfluxDB connections to open. Connections are opened + lazily. Users using multi-threaded application servers should ensure + enough connections are available (at minimum the amount of application + server threads). + .form-group + = f.label :metrics_timeout, 'Connection timeout', class: 'label-light' + = f.number_field :metrics_timeout, class: 'form-control' + .form-text.text-muted + The amount of seconds after which an InfluxDB connection will time + out. + .form-group + = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'label-light' + = f.number_field :metrics_method_call_threshold, class: 'form-control' + .form-text.text-muted + A method call is only tracked when it takes longer to complete than + the given amount of milliseconds. + .form-group + = f.label :metrics_sample_interval, 'Sampler Interval (sec)', class: 'label-light' + = f.number_field :metrics_sample_interval, class: 'form-control' + .form-text.text-muted + The sampling interval in seconds. Sampled data includes memory usage, + retained Ruby objects, file descriptors and so on. + .form-group + = f.label :metrics_packet_size, 'Metrics per packet', class: 'label-light' + = f.number_field :metrics_packet_size, class: 'form-control' + .form-text.text-muted + The amount of points to store in a single UDP packet. More points + results in fewer but larger UDP packets being sent. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml index 320dd52ffc2..73d570a5fee 100644 --- a/app/views/admin/application_settings/_ip_limits.html.haml +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -2,53 +2,44 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :throttle_unauthenticated_enabled, class: 'form-check-input' - = f.label :throttle_unauthenticated_enabled, class: 'form-check-label' do - Enable unauthenticated request rate limit - %span.form-text.text-muted - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group.row - = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' - .form-group.row - = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :throttle_authenticated_api_enabled, class: 'form-check-input' - = f.label :throttle_authenticated_api_enabled, class: 'form-check-label' do - Enable authenticated API request rate limit - %span.form-text.text-muted - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group.row - = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' - .form-group.row - = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :throttle_authenticated_web_enabled, class: 'form-check-input' - = f.label :throttle_authenticated_web_enabled, class: 'form-check-label' do - Enable authenticated web request rate limit - %span.form-text.text-muted - Helps reduce request volume (e.g. from crawlers or abusive bots) - .form-group.row - = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' - .form-group.row - = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' + .form-group + .form-check + = f.check_box :throttle_unauthenticated_enabled, class: 'form-check-input' + = f.label :throttle_unauthenticated_enabled, class: 'form-check-label' do + Enable unauthenticated request rate limit + %span.form-text.text-muted + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'label-light' + = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'label-light' + = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' + .form-group + .form-check + = f.check_box :throttle_authenticated_api_enabled, class: 'form-check-input' + = f.label :throttle_authenticated_api_enabled, class: 'form-check-label' do + Enable authenticated API request rate limit + %span.form-text.text-muted + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'label-light' + = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'label-light' + = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' + .form-group + .form-check + = f.check_box :throttle_authenticated_web_enabled, class: 'form-check-input' + = f.label :throttle_authenticated_web_enabled, class: 'form-check-label' do + Enable authenticated web request rate limit + %span.form-text.text-muted + Helps reduce request volume (e.g. from crawlers or abusive bots) + .form-group + = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'label-light' + = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' + .form-group + = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'label-light' + = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_koding.html.haml b/app/views/admin/application_settings/_koding.html.haml index 341c7641fcc..ae60f68f5fe 100644 --- a/app/views/admin/application_settings/_koding.html.haml +++ b/app/views/admin/application_settings/_koding.html.haml @@ -2,23 +2,21 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :koding_enabled, class: 'form-check-input' - = f.label :koding_enabled, class: 'form-check-label' do - Enable Koding - .form-text.text-muted - Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. - .form-group.row - = f.label :koding_url, 'Koding URL', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' - .form-text.text-muted - Koding has integration enabled out of the box for the - %strong gitlab - team, and you need to provide that team's URL here. Learn more in the - = succeed "." do - = link_to "Koding administration documentation", help_page_path("administration/integration/koding") + .form-group + .form-check + = f.check_box :koding_enabled, class: 'form-check-input' + = f.label :koding_enabled, class: 'form-check-label' do + Enable Koding + .form-text.text-muted + Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. + .form-group + = f.label :koding_url, 'Koding URL', class: 'label-light' + = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' + .form-text.text-muted + Koding has integration enabled out of the box for the + %strong gitlab + team, and you need to provide that team's URL here. Learn more in the + = succeed "." do + = link_to "Koding administration documentation", help_page_path("administration/integration/koding") = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml index f5c1e126c70..a6e549cd1f0 100644 --- a/app/views/admin/application_settings/_logging.html.haml +++ b/app/views/admin/application_settings/_logging.html.haml @@ -2,35 +2,31 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :sentry_enabled, class: 'form-check-input' - = f.label :sentry_enabled, class: 'form-check-label' do - Enable Sentry - .form-text.text-muted - %p This setting requires a restart to take effect. - Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: - %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com + .form-group + .form-check + = f.check_box :sentry_enabled, class: 'form-check-input' + = f.label :sentry_enabled, class: 'form-check-label' do + Enable Sentry + .form-text.text-muted + %p This setting requires a restart to take effect. + Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: + %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com - .form-group.row - = f.label :sentry_dsn, 'Sentry DSN', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :sentry_dsn, class: 'form-control' + .form-group + = f.label :sentry_dsn, 'Sentry DSN', class: 'label-light' + = f.text_field :sentry_dsn, class: 'form-control' - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :clientside_sentry_enabled, class: 'form-check-input' - = f.label :clientside_sentry_enabled, class: 'form-check-label' do - Enable Clientside Sentry - .form-text.text-muted - Sentry can also be used for reporting and logging clientside exceptions. - %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ + .form-group + .form-check + = f.check_box :clientside_sentry_enabled, class: 'form-check-input' + = f.label :clientside_sentry_enabled, class: 'form-check-label' do + Enable Clientside Sentry + .form-text.text-muted + Sentry can also be used for reporting and logging clientside exceptions. + %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ - .form-group.row - = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :clientside_sentry_dsn, class: 'form-control' + .form-group + = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'label-light' + = f.text_field :clientside_sentry_dsn, class: 'form-control' = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index 5dadb7b814b..e046242bee0 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -2,11 +2,10 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :allow_local_requests_from_hooks_and_services, class: 'form-check-input' - = f.label :allow_local_requests_from_hooks_and_services, class: 'form-check-label' do - Allow requests to the local network from hooks and services + .form-group + .form-check + = f.check_box :allow_local_requests_from_hooks_and_services, class: 'form-check-input' + = f.label :allow_local_requests_from_hooks_and_services, class: 'form-check-label' do + Allow requests to the local network from hooks and services = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index f1889c3105f..f168ec62ffd 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -2,21 +2,19 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :max_pages_size, class: 'form-control' - .form-text.text-muted 0 for unlimited - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :pages_domain_verification_enabled, class: 'form-check-input' - = f.label :pages_domain_verification_enabled, class: 'form-check-label' do - Require users to prove ownership of custom domains - .form-text.text-muted - Domain verification is an essential security measure for public GitLab - sites. Users are required to demonstrate they control a domain before - it is enabled - = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') + .form-group + = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'label-light' + = f.number_field :max_pages_size, class: 'form-control' + .form-text.text-muted 0 for unlimited + .form-group + .form-check + = f.check_box :pages_domain_verification_enabled, class: 'form-check-input' + = f.label :pages_domain_verification_enabled, class: 'form-check-label' do + Require users to prove ownership of custom domains + .form-text.text-muted + Domain verification is an essential security measure for public GitLab + sites. Users are required to demonstrate they control a domain before + it is enabled + = link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index 57c22ce563f..ffa25af77ed 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -2,18 +2,17 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :authorized_keys_enabled, class: 'form-check-input' - = f.label :authorized_keys_enabled, class: 'form-check-label' do - Write to "authorized_keys" file - .form-text.text-muted - By default, we write to the "authorized_keys" file to support Git - over SSH without additional configuration. GitLab can be optimized - to authenticate SSH keys via the database file. Only uncheck this - if you have configured your OpenSSH server to use the - AuthorizedKeysCommand. Click on the help icon for more details. - = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup') + .form-group + .form-check + = f.check_box :authorized_keys_enabled, class: 'form-check-input' + = f.label :authorized_keys_enabled, class: 'form-check-label' do + Write to "authorized_keys" file + .form-text.text-muted + By default, we write to the "authorized_keys" file to support Git + over SSH without additional configuration. GitLab can be optimized + to authenticate SSH keys via the database file. Only uncheck this + if you have configured your OpenSSH server to use the + AuthorizedKeysCommand. Click on the help icon for more details. + = link_to icon('question-circle'), help_page_path('administration/operations/fast_ssh_key_lookup') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index ed4de2234f7..ddbfcc6b77b 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -2,15 +2,13 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :performance_bar_enabled, class: 'form-check-input' - = f.label :performance_bar_enabled, class: 'form-check-label' do - Enable the Performance Bar - .form-group.row - = f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :performance_bar_allowed_group_path, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path + .form-group + .form-check + = f.check_box :performance_bar_enabled, class: 'form-check-input' + = f.label :performance_bar_enabled, class: 'form-check-label' do + Enable the Performance Bar + .form-group + = f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-light' + = f.text_field :performance_bar_allowed_group_path, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index e0dc058762e..259f18b3b96 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -2,19 +2,17 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :plantuml_enabled, class: 'form-check-input' - = f.label :plantuml_enabled, class: 'form-check-label' do - Enable PlantUML - .form-group.row - = f.label :plantuml_url, 'PlantUML URL', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' - .form-text.text-muted - Allow rendering of - = link_to "PlantUML", "http://plantuml.com" - diagrams in Asciidoc documents using an external PlantUML service. + .form-group + .form-check + = f.check_box :plantuml_enabled, class: 'form-check-input' + = f.label :plantuml_enabled, class: 'form-check-label' do + Enable PlantUML + .form-group + = f.label :plantuml_url, 'PlantUML URL', class: 'label-light' + = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' + .form-text.text-muted + Allow rendering of + = link_to "PlantUML", "http://plantuml.com" + diagrams in Asciidoc documents using an external PlantUML service. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index d3c3656e96a..ad92b18b2c9 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -11,18 +11,17 @@ = link_to 'restart', help_page_path('administration/restart_gitlab') to take effect. = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/index') - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :prometheus_metrics_enabled, class: 'form-check-input' - = f.label :prometheus_metrics_enabled, class: 'form-check-label' do - Enable Prometheus Metrics - - unless Gitlab::Metrics.metrics_folder_present? - .form-text.text-muted - %strong.cred WARNING: - Environment variable - %code prometheus_multiproc_dir - does not exist or is not pointing to a valid directory. - = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') + .form-group + .form-check + = f.check_box :prometheus_metrics_enabled, class: 'form-check-input' + = f.label :prometheus_metrics_enabled, class: 'form-check-label' do + Enable Prometheus Metrics + - unless Gitlab::Metrics.metrics_folder_present? + .form-text.text-muted + %strong.cred WARNING: + Environment variable + %code prometheus_multiproc_dir + does not exist or is not pointing to a valid directory. + = link_to icon('question-circle'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_realtime.html.haml b/app/views/admin/application_settings/_realtime.html.haml index 63a592cc2fd..120cf4909b2 100644 --- a/app/views/admin/application_settings/_realtime.html.haml +++ b/app/views/admin/application_settings/_realtime.html.haml @@ -2,18 +2,17 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :polling_interval_multiplier, class: 'form-control' - .form-text.text-muted - Change this value to influence how frequently the GitLab UI polls for updates. - If you set the value to 2 all polling intervals are multiplied - by 2, which means that polling happens half as frequently. - The multiplier can also have a decimal value. - The default value (1) is a reasonable choice for the majority of GitLab - installations. Set to 0 to completely disable polling. - = link_to icon('question-circle'), help_page_path('administration/polling') + .form-group + = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'label-light' + = f.text_field :polling_interval_multiplier, class: 'form-control' + .form-text.text-muted + Change this value to influence how frequently the GitLab UI polls for updates. + If you set the value to 2 all polling intervals are multiplied + by 2, which means that polling happens half as frequently. + The multiplier can also have a decimal value. + The default value (1) is a reasonable choice for the majority of GitLab + installations. Set to 0 to completely disable polling. + = link_to icon('question-circle'), help_page_path('administration/polling') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index 8524cbfe4d9..beac70482e5 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -2,9 +2,8 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :container_registry_token_expire_delay, class: 'form-control' + .form-group + = f.label :container_registry_token_expire_delay, 'Authorization token duration (minutes)', class: 'label-light' + = f.number_field :container_registry_token_expire_delay, class: 'form-control' = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index 1311f17ecda..57facc380eb 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -4,59 +4,53 @@ %fieldset .sub-section %h4 Repository checks - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :repository_checks_enabled, class: 'form-check-input' - = f.label :repository_checks_enabled, class: 'form-check-label' do - Enable Repository Checks - .form-text.text-muted - GitLab will periodically run - %a{ href: 'https://git-scm.com/docs/git-fsck', target: 'blank' } 'git fsck' - in all project and wiki repositories to look for silent disk corruption issues. - .form-group.row - .offset-sm-2.col-sm-10 - = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove" + .form-group + .form-check + = f.check_box :repository_checks_enabled, class: 'form-check-input' + = f.label :repository_checks_enabled, class: 'form-check-label' do + Enable Repository Checks .form-text.text-muted - If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. + GitLab will periodically run + %a{ href: 'https://git-scm.com/docs/git-fsck', target: 'blank' } 'git fsck' + in all project and wiki repositories to look for silent disk corruption issues. + .form-group + = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove" + .form-text.text-muted + If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. .sub-section %h4 Housekeeping - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :housekeeping_enabled, class: 'form-check-input' - = f.label :housekeeping_enabled, class: 'form-check-label' do - Enable automatic repository housekeeping (git repack, git gc) - .form-text.text-muted - If you keep automatic housekeeping disabled for a long time Git - repository access on your GitLab server will become slower and your - repositories will use more disk space. We recommend to always leave - this enabled. - .form-check - = f.check_box :housekeeping_bitmaps_enabled, class: 'form-check-input' - = f.label :housekeeping_bitmaps_enabled, class: 'form-check-label' do - Enable Git pack file bitmap creation - .form-text.text-muted - Creating pack file bitmaps makes housekeeping take a little longer but - bitmaps should accelerate 'git clone' performance. - .form-group.row - = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_incremental_repack_period, class: 'form-control' + .form-group + .form-check + = f.check_box :housekeeping_enabled, class: 'form-check-input' + = f.label :housekeeping_enabled, class: 'form-check-label' do + Enable automatic repository housekeeping (git repack, git gc) .form-text.text-muted - Number of Git pushes after which an incremental 'git repack' is run. - .form-group.row - = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_full_repack_period, class: 'form-control' + If you keep automatic housekeeping disabled for a long time Git + repository access on your GitLab server will become slower and your + repositories will use more disk space. We recommend to always leave + this enabled. + .form-check + = f.check_box :housekeeping_bitmaps_enabled, class: 'form-check-input' + = f.label :housekeeping_bitmaps_enabled, class: 'form-check-label' do + Enable Git pack file bitmap creation .form-text.text-muted - Number of Git pushes after which a full 'git repack' is run. - .form-group.row - = f.label :housekeeping_gc_period, 'Git GC period', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :housekeeping_gc_period, class: 'form-control' - .form-text.text-muted - Number of Git pushes after which 'git gc' is run. + Creating pack file bitmaps makes housekeeping take a little longer but + bitmaps should accelerate 'git clone' performance. + .form-group + = f.label :housekeeping_incremental_repack_period, 'Incremental repack period', class: 'label-light' + = f.number_field :housekeeping_incremental_repack_period, class: 'form-control' + .form-text.text-muted + Number of Git pushes after which an incremental 'git repack' is run. + .form-group + = f.label :housekeeping_full_repack_period, 'Full repack period', class: 'label-light' + = f.number_field :housekeeping_full_repack_period, class: 'form-control' + .form-text.text-muted + Number of Git pushes after which a full 'git repack' is run. + .form-group + = f.label :housekeeping_gc_period, 'Git GC period', class: 'label-light' + = f.number_field :housekeeping_gc_period, class: 'form-control' + .form-text.text-muted + Number of Git pushes after which 'git gc' is run. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index 187c6c28bb1..beeb5169361 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -2,15 +2,14 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :mirror_available, 'Enable mirror configuration', class: 'control-label col-sm-4' - .col-sm-8 - .form-check - = f.check_box :mirror_available, class: 'form-check-input' - = f.label :mirror_available, class: 'form-check-label' do - Allow mirrors to be setup for projects - %span.form-text.text-muted - If disabled, only admins will be able to setup mirrors in projects. - = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring') + .form-group + = f.label :mirror_available, 'Enable mirror configuration', class: 'label-light' + .form-check + = f.check_box :mirror_available, class: 'form-check-input' + = f.label :mirror_available, class: 'form-check-label' do + Allow mirrors to be setup for projects + %span.form-text.text-muted + If disabled, only admins will be able to setup mirrors in projects. + = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 89d2c114b22..5a303666353 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -3,56 +3,49 @@ %fieldset .sub-section - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :hashed_storage_enabled, class: 'form-check-input' - = f.label :hashed_storage_enabled, class: 'form-check-label' do - Create new projects using hashed storage paths - .form-text.text-muted - Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents - repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. - %em (EXPERIMENTAL) - .form-group.row - = f.label :repository_storages, 'Storage paths for new projects', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), - {include_hidden: false}, multiple: true, class: 'form-control' + .form-group + .form-check + = f.check_box :hashed_storage_enabled, class: 'form-check-input' + = f.label :hashed_storage_enabled, class: 'form-check-label' do + Create new projects using hashed storage paths .form-text.text-muted - Manage repository storage paths. Learn more in the - = succeed "." do - = link_to "repository storages documentation", help_page_path("administration/repository_storage_paths") + Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents + repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. + %em (EXPERIMENTAL) + .form-group + = f.label :repository_storages, 'Storage paths for new projects', class: 'label-light' + = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), + {include_hidden: false}, multiple: true, class: 'form-control' + .form-text.text-muted + Manage repository storage paths. Learn more in the + = succeed "." do + = link_to "repository storages documentation", help_page_path("administration/repository_storage_paths") .sub-section %h4 Circuit breaker - .form-group.row - = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_check_interval, class: 'form-control' - .form-text.text-muted - = circuitbreaker_check_interval_help_text - .form-group.row - = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_access_retries, class: 'form-control' - .form-text.text-muted - = circuitbreaker_access_retries_help_text - .form-group.row - = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_storage_timeout, class: 'form-control' - .form-text.text-muted - = circuitbreaker_storage_timeout_help_text - .form-group.row - = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control' - .form-text.text-muted - = circuitbreaker_failure_count_help_text - .form-group.row - = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control' - .form-text.text-muted - = circuitbreaker_failure_reset_time_help_text + .form-group + = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'label-light' + = f.number_field :circuitbreaker_check_interval, class: 'form-control' + .form-text.text-muted + = circuitbreaker_check_interval_help_text + .form-group + = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'label-light' + = f.number_field :circuitbreaker_access_retries, class: 'form-control' + .form-text.text-muted + = circuitbreaker_access_retries_help_text + .form-group + = f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'label-light' + = f.number_field :circuitbreaker_storage_timeout, class: 'form-control' + .form-text.text-muted + = circuitbreaker_storage_timeout_help_text + .form-group + = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'label-light' + = f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control' + .form-text.text-muted + = circuitbreaker_failure_count_help_text + .form-group + = f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'label-light' + = f.number_field :circuitbreaker_failure_reset_time, class: 'form-control' + .form-text.text-muted + = circuitbreaker_failure_reset_time_help_text = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 2ba26158162..69d1a43c511 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -2,59 +2,51 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :password_authentication_enabled_for_web, class: 'form-check-input' - = f.label :password_authentication_enabled_for_web, class: 'form-check-label' do - Password authentication enabled for web interface - .form-text.text-muted - When disabled, an external authentication provider must be used. - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :password_authentication_enabled_for_git, class: 'form-check-input' - = f.label :password_authentication_enabled_for_git, class: 'form-check-label' do - Password authentication enabled for Git over HTTP(S) - .form-text.text-muted - When disabled, a Personal Access Token - - if Gitlab::Auth::LDAP::Config.enabled? - or LDAP password - must be used to authenticate. + .form-group + .form-check + = f.check_box :password_authentication_enabled_for_web, class: 'form-check-input' + = f.label :password_authentication_enabled_for_web, class: 'form-check-label' do + Password authentication enabled for web interface + .form-text.text-muted + When disabled, an external authentication provider must be used. + .form-group + .form-check + = f.check_box :password_authentication_enabled_for_git, class: 'form-check-input' + = f.label :password_authentication_enabled_for_git, class: 'form-check-label' do + Password authentication enabled for Git over HTTP(S) + .form-text.text-muted + When disabled, a Personal Access Token + - if Gitlab::Auth::LDAP::Config.enabled? + or LDAP password + must be used to authenticate. - if omniauth_enabled? && button_based_providers.any? - .form-group.row - = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'col-form-label col-sm-2' + .form-group + = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'label-light' = hidden_field_tag 'application_setting[enabled_oauth_sign_in_sources][]' - .col-sm-10 - .btn-group{ data: { toggle: 'buttons' } } - - oauth_providers_checkboxes.each do |source| - = source - .form-group.row - = f.label :two_factor_authentication, 'Two-factor authentication', class: 'col-form-label col-sm-2' - .col-sm-10 - .form-check - = f.check_box :require_two_factor_authentication, class: 'form-check-input' - = f.label :require_two_factor_authentication, class: 'form-check-label' do - Require all users to setup Two-factor authentication - .form-group.row - = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0' - .form-text.text-muted Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication - .form-group.row - = f.label :home_page_url, 'Home page URL', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block' - %span.form-text.text-muted#home_help_block We will redirect non-logged in users to this page - .form-group.row - = f.label :after_sign_out_path, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block' - %span.form-text.text-muted#after_sign_out_path_help_block We will redirect users to this page after they sign out - .form-group.row - = f.label :sign_in_text, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_area :sign_in_text, class: 'form-control', rows: 4 - .form-text.text-muted Markdown enabled + .btn-group{ data: { toggle: 'buttons' } } + - oauth_providers_checkboxes.each do |source| + = source + .form-group + = f.label :two_factor_authentication, 'Two-factor authentication', class: 'label-light' + .form-check + = f.check_box :require_two_factor_authentication, class: 'form-check-input' + = f.label :require_two_factor_authentication, class: 'form-check-label' do + Require all users to setup Two-factor authentication + .form-group + = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'label-light' + = f.number_field :two_factor_grace_period, min: 0, class: 'form-control', placeholder: '0' + .form-text.text-muted Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication + .form-group + = f.label :home_page_url, 'Home page URL', class: 'label-light' + = f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block' + %span.form-text.text-muted#home_help_block We will redirect non-logged in users to this page + .form-group + = f.label :after_sign_out_path, class: 'label-light' + = f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block' + %span.form-text.text-muted#after_sign_out_path_help_block We will redirect users to this page after they sign out + .form-group + = f.label :sign_in_text, class: 'label-light' + = f.text_area :sign_in_text, class: 'form-control', rows: 4 + .form-text.text-muted Markdown enabled = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index 279f96389e9..b9ba9128cc9 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -2,57 +2,49 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :signup_enabled, class: 'form-check-input' - = f.label :signup_enabled, class: 'form-check-label' do - Sign-up enabled - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :send_user_confirmation_email, class: 'form-check-input' - = f.label :send_user_confirmation_email, class: 'form-check-label' do - Send confirmation email on sign-up - .form-group.row - = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 - .form-text.text-muted ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com - .form-group.row - = f.label :domain_blacklist_enabled, 'Domain Blacklist', class: 'col-form-label col-sm-2' - .col-sm-10 - .form-check - = f.check_box :domain_blacklist_enabled, class: 'form-check-input' - = f.label :domain_blacklist_enabled, class: 'form-check-label' do - Enable domain blacklist for sign ups - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = radio_button_tag :blacklist_type, :file, class: 'form-check-input' - = label_tag :blacklist_type_file, class: 'form-check-label' do - .option-title - Upload blacklist file - .form-check - = radio_button_tag :blacklist_type, :raw, @application_setting.domain_blacklist.present? || @application_setting.domain_blacklist.blank?, class: 'form-check-input' - = label_tag :blacklist_type_raw, class: 'form-check-label' do - .option-title - Enter blacklist manually - .form-group.row.blacklist-file - = f.label :domain_blacklist_file, 'Blacklist file', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.file_field :domain_blacklist_file, class: 'form-control', accept: '.txt,.conf' - .form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries. - .form-group.row.blacklist-raw - = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 - .form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com + .form-group + .form-check + = f.check_box :signup_enabled, class: 'form-check-input' + = f.label :signup_enabled, class: 'form-check-label' do + Sign-up enabled + .form-group + .form-check + = f.check_box :send_user_confirmation_email, class: 'form-check-input' + = f.label :send_user_confirmation_email, class: 'form-check-label' do + Send confirmation email on sign-up + .form-group + = f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'label-light' + = f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 + .form-text.text-muted ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com + .form-group + = f.label :domain_blacklist_enabled, 'Domain Blacklist', class: 'label-light' + .form-check + = f.check_box :domain_blacklist_enabled, class: 'form-check-input' + = f.label :domain_blacklist_enabled, class: 'form-check-label' do + Enable domain blacklist for sign ups + .form-group + .form-check + = radio_button_tag :blacklist_type, :file, false, class: 'form-check-input' + = label_tag :blacklist_type_file, class: 'form-check-label' do + .option-title + Upload blacklist file + .form-check + = radio_button_tag :blacklist_type, :raw, @application_setting.domain_blacklist.present? || @application_setting.domain_blacklist.blank?, class: 'form-check-input' + = label_tag :blacklist_type_raw, class: 'form-check-label' do + .option-title + Enter blacklist manually + .form-group.blacklist-file + = f.label :domain_blacklist_file, 'Blacklist file', class: 'label-light' + = f.file_field :domain_blacklist_file, class: 'form-control', accept: '.txt,.conf' + .form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries. + .form-group.blacklist-raw + = f.label :domain_blacklist, 'Blacklisted domains for sign-ups', class: 'label-light' + = f.text_area :domain_blacklist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8 + .form-text.text-muted Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com - .form-group.row - = f.label :after_sign_up_text, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 - .form-text.text-muted Markdown enabled + .form-group + = f.label :after_sign_up_text, class: 'label-light' + = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 + .form-text.text-muted Markdown enabled = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index fb38e4ae922..8f0dce962a9 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -2,64 +2,56 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :recaptcha_enabled, class: 'form-check-input' - = f.label :recaptcha_enabled, class: 'form-check-label' do - Enable reCAPTCHA - %span.form-text.text-muted#recaptcha_help_block Helps prevent bots from creating accounts - - .form-group.row - = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :recaptcha_site_key, class: 'form-control' - .form-text.text-muted - Generate site and private keys at - %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha - - .form-group.row - = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :recaptcha_private_key, class: 'form-control' - - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :akismet_enabled, class: 'form-check-input' - = f.label :akismet_enabled, class: 'form-check-label' do - Enable Akismet - %span.form-text.text-muted#akismet_help_block Helps prevent bots from creating issues - - .form-group.row - = f.label :akismet_api_key, 'Akismet API Key', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.text_field :akismet_api_key, class: 'form-control' - .form-text.text-muted - Generate API key at - %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com - - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :unique_ips_limit_enabled, class: 'form-check-input' - = f.label :unique_ips_limit_enabled, class: 'form-check-label' do - Limit sign in from multiple ips - %span.form-text.text-muted#unique_ip_help_block - Helps prevent malicious users hide their activity - - .form-group.row - = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :unique_ips_limit_per_user, class: 'form-control' - .form-text.text-muted - Maximum number of unique IPs per user - - .form-group.row - = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :unique_ips_limit_time_window, class: 'form-control' - .form-text.text-muted - How many seconds an IP will be counted towards the limit + .form-group + .form-check + = f.check_box :recaptcha_enabled, class: 'form-check-input' + = f.label :recaptcha_enabled, class: 'form-check-label' do + Enable reCAPTCHA + %span.form-text.text-muted#recaptcha_help_block Helps prevent bots from creating accounts + + .form-group + = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'label-light' + = f.text_field :recaptcha_site_key, class: 'form-control' + .form-text.text-muted + Generate site and private keys at + %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha + + .form-group + = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'label-light' + = f.text_field :recaptcha_private_key, class: 'form-control' + + .form-group + .form-check + = f.check_box :akismet_enabled, class: 'form-check-input' + = f.label :akismet_enabled, class: 'form-check-label' do + Enable Akismet + %span.form-text.text-muted#akismet_help_block Helps prevent bots from creating issues + + .form-group + = f.label :akismet_api_key, 'Akismet API Key', class: 'label-light' + = f.text_field :akismet_api_key, class: 'form-control' + .form-text.text-muted + Generate API key at + %a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com + + .form-group + .form-check + = f.check_box :unique_ips_limit_enabled, class: 'form-check-input' + = f.label :unique_ips_limit_enabled, class: 'form-check-label' do + Limit sign in from multiple ips + %span.form-text.text-muted#unique_ip_help_block + Helps prevent malicious users hide their activity + + .form-group + = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'label-light' + = f.number_field :unique_ips_limit_per_user, class: 'form-control' + .form-text.text-muted + Maximum number of unique IPs per user + + .form-group + = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'label-light' + = f.number_field :unique_ips_limit_time_window, class: 'form-control' + .form-text.text-muted + How many seconds an IP will be counted towards the limit = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index ae02d07e556..543628ff0ee 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -2,12 +2,11 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :terminal_max_session_time, 'Max session time', class: 'col-form-label col-sm-2' - .col-sm-10 - = f.number_field :terminal_max_session_time, class: 'form-control' - .form-text.text-muted - Maximum time for web terminal websocket connection (in seconds). - 0 for unlimited. + .form-group + = f.label :terminal_max_session_time, 'Max session time', class: 'label-light' + = f.number_field :terminal_max_session_time, class: 'form-control' + .form-text.text-muted + Maximum time for web terminal websocket connection (in seconds). + 0 for unlimited. = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml index 257565ce193..d3dc8659d1b 100644 --- a/app/views/admin/application_settings/_terms.html.haml +++ b/app/views/admin/application_settings/_terms.html.haml @@ -1,22 +1,19 @@ -= form_for @application_setting, url: admin_application_settings_path do |f| += form_for @application_setting, url: admin_application_settings_path, html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset - .form-group.row - .col-sm-12 - .form-check - = f.check_box :enforce_terms, class: 'form-check-input' - = f.label :enforce_terms, class: 'form-check-label' do - = _("Require all users to accept Terms of Service when they access GitLab.") - .form-text.text-muted - = _("When enabled, users cannot use GitLab until the terms have been accepted.") - .form-group.row - .col-sm-12 - = f.label :terms do - = _("Terms of Service Agreement") - .col-sm-12 - = f.text_area :terms, class: 'form-control', rows: 8 + .form-group + .form-check + = f.check_box :enforce_terms, class: 'form-check-input' + = f.label :enforce_terms, class: 'form-check-label' do + = _("Require all users to accept Terms of Service and Privacy Policy when they access GitLab.") .form-text.text-muted - = _("Markdown enabled") + = _("When enabled, users cannot use GitLab until the terms have been accepted.") + .form-group + = f.label :terms do + = _("Terms of Service Agreement and Privacy Policy") + = f.text_area :terms, class: 'form-control', rows: 8 + .form-text.text-muted + = _("Markdown enabled") = f.submit _("Save changes"), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index c110fd4d60d..49a3ee33a85 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -2,36 +2,34 @@ = form_errors(@application_setting) %fieldset - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - = f.check_box :version_check_enabled, class: 'form-check-input' - = f.label :version_check_enabled, class: 'form-check-label' do - Enable version check - .form-text.text-muted - GitLab will inform you if a new version is available. - = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") - about what information is shared with GitLab Inc. - .form-group.row - .offset-sm-2.col-sm-10 - - can_be_configured = @application_setting.usage_ping_can_be_configured? - .form-check - = f.check_box :usage_ping_enabled, disabled: !can_be_configured, class: 'form-check-input' - = f.label :usage_ping_enabled, class: 'form-check-label' do - Enable usage ping - .form-text.text-muted - - if can_be_configured - To help improve GitLab and its user experience, GitLab will - periodically collect usage information. - = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") - about what information is shared with GitLab Inc. Visit - = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping') - to see the JSON payload sent. - - else - The usage ping is disabled, and cannot be configured through this - form. For more information, see the documentation on - = succeed '.' do - = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') + .form-group + .form-check + = f.check_box :version_check_enabled, class: 'form-check-input' + = f.label :version_check_enabled, class: 'form-check-label' do + Enable version check + .form-text.text-muted + GitLab will inform you if a new version is available. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") + about what information is shared with GitLab Inc. + .form-group + - can_be_configured = @application_setting.usage_ping_can_be_configured? + .form-check + = f.check_box :usage_ping_enabled, disabled: !can_be_configured, class: 'form-check-input' + = f.label :usage_ping_enabled, class: 'form-check-label' do + Enable usage ping + .form-text.text-muted + - if can_be_configured + To help improve GitLab and its user experience, GitLab will + periodically collect usage information. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") + about what information is shared with GitLab Inc. Visit + = link_to 'Cohorts', admin_cohorts_path(anchor: 'usage-ping') + to see the JSON payload sent. + - else + The usage ping is disabled, and cannot be configured through this + form. For more information, see the documentation on + = succeed '.' do + = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 2b4d3bab54d..4cc3e6a7d03 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -2,66 +2,57 @@ = form_errors(@application_setting) %fieldset - .form-group.row - = f.label :default_branch_protection, class: 'col-form-label col-sm-2' - .col-sm-10 - = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control' - .form-group.row.visibility-level-setting - = f.label :default_project_visibility, class: 'col-form-label col-sm-2' - .col-sm-10 - = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) - .form-group.row.visibility-level-setting - = f.label :default_snippet_visibility, class: 'col-form-label col-sm-2' - .col-sm-10 - = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new) - .form-group.row.visibility-level-setting - = f.label :default_group_visibility, class: 'col-form-label col-sm-2' - .col-sm-10 - = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new) - .form-group.row - = f.label :restricted_visibility_levels, class: 'col-form-label col-sm-2' - .col-sm-10 - - checkbox_name = 'application_setting[restricted_visibility_levels][]' - = hidden_field_tag(checkbox_name) - - restricted_level_checkboxes('restricted-visibility-help', checkbox_name).each do |level| - .form-check - = level - %span.form-text.text-muted#restricted-visibility-help - Selected levels cannot be used by non-admin users for groups, projects or snippets. - If the public level is restricted, user profiles are only visible to logged in users. - .form-group.row - = f.label :import_sources, class: 'col-form-label col-sm-2' - .col-sm-10 - = hidden_field_tag 'application_setting[import_sources][]' - - import_sources_checkboxes('import-sources-help').each do |source| - .form-check= source - %span.form-text.text-muted#import-sources-help - Enabled sources for code import during project creation. OmniAuth must be configured for GitHub - = link_to "(?)", help_page_path("integration/github") - , Bitbucket - = link_to "(?)", help_page_path("integration/bitbucket") - and GitLab.com - = link_to "(?)", help_page_path("integration/gitlab") - - .form-group.row - .offset-sm-2.col-sm-10 + .form-group + = f.label :default_branch_protection, class: 'label-light' + = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control' + .form-group.visibility-level-setting + = f.label :default_project_visibility, class: 'label-light' + = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) + .form-group.visibility-level-setting + = f.label :default_snippet_visibility, class: 'label-light' + = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new) + .form-group.visibility-level-setting + = f.label :default_group_visibility, class: 'label-light' + = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new) + .form-group + = f.label :restricted_visibility_levels, class: 'label-light' + - checkbox_name = 'application_setting[restricted_visibility_levels][]' + = hidden_field_tag(checkbox_name) + - restricted_level_checkboxes('restricted-visibility-help', checkbox_name, class: 'form-check-input').each do |level| .form-check - = f.check_box :project_export_enabled, class: 'form-check-input' - = f.label :project_export_enabled, class: 'form-check-label' do - Project export enabled + = level + %span.form-text.text-muted#restricted-visibility-help + Selected levels cannot be used by non-admin users for groups, projects or snippets. + If the public level is restricted, user profiles are only visible to logged in users. + .form-group + = f.label :import_sources, class: 'label-light' + = hidden_field_tag 'application_setting[import_sources][]' + - import_sources_checkboxes('import-sources-help', class: 'form-check-input').each do |source| + .form-check= source + %span.form-text.text-muted#import-sources-help + Enabled sources for code import during project creation. OmniAuth must be configured for GitHub + = link_to "(?)", help_page_path("integration/github") + , Bitbucket + = link_to "(?)", help_page_path("integration/bitbucket") + and GitLab.com + = link_to "(?)", help_page_path("integration/gitlab") + + .form-group + .form-check + = f.check_box :project_export_enabled, class: 'form-check-input' + = f.label :project_export_enabled, class: 'form-check-label' do + Project export enabled - .form-group.row - %label.col-form-label.col-sm-2 Enabled Git access protocols - .col-sm-10 - = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') - %span.form-text.text-muted#clone-protocol-help - Allow only the selected protocols to be used for Git access. + .form-group + %label.label-light Enabled Git access protocols + = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') + %span.form-text.text-muted#clone-protocol-help + Allow only the selected protocols to be used for Git access. - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| - field_name = :"#{type}_key_restriction" - .form-group.row - = f.label field_name, "#{type.upcase} SSH keys", class: 'col-form-label col-sm-2' - .col-sm-10 - = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' + .form-group + = f.label field_name, "#{type.upcase} SSH keys", class: 'label-light' + = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index 3f440c76ee0..bd43504dd37 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -17,7 +17,7 @@ %section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded) } .settings-header %h4 - = _('Account and limit settings') + = _('Account and limit') %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p @@ -50,11 +50,11 @@ %section.settings.as-terms.no-animate#js-terms-settings{ class: ('expanded' if expanded) } .settings-header %h4 - = _('Terms of Service') + = _('Terms of Service and Privacy Policy') %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _('Include a Terms of Service agreement that all users must accept.') + = _('Include a Terms of Service agreement and Privacy Policy that all users must accept.') .settings-content = render 'terms' @@ -169,7 +169,7 @@ .settings-content = render 'logging' -%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded) } +%section.qa-repository-storage-settings.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded) } .settings-header %h4 = _('Repository storage') @@ -317,10 +317,13 @@ %section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded) } .settings-header %h4 - = _('Repository mirror settings') + = _('Repository mirror') %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p = _('Configure push mirrors.') .settings-content = render partial: 'repository_mirrors_form' + += render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded + diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 3cdeb103bb8..18f2c1a509f 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -2,7 +2,7 @@ - breadcrumb_title "Dashboard" %div{ class: container_class } - = render_if_exists "admin/licenses/breakdown", license: @license + = render_if_exists 'admin/licenses/breakdown', license: @license .admin-dashboard.prepend-top-default .row @@ -22,7 +22,7 @@ %h3.text-center Users: = approximate_count_with_delimiters(@counts, User) - = render_if_exists 'users_statistics' + = render_if_exists 'admin/dashboard/users_statistics' %hr = link_to 'New user', new_admin_user_path, class: "btn btn-new" .col-sm-4 @@ -101,7 +101,7 @@ %span.light.float-right = boolean_to_icon Gitlab::IncomingEmail.enabled? - = render_if_exists 'elastic_and_geo' + = render_if_exists 'admin/dashboard/elastic_and_geo' - container_reg = "Container Registry" %p{ "aria-label" => "#{container_reg}: status " + (Gitlab.config.registry.enabled ? "on" : "off") } @@ -151,7 +151,7 @@ %span.float-right = Gitlab::Pages::VERSION - = render_if_exists 'geo' + = render_if_exists 'admin/dashboard/geo' %p Ruby diff --git a/app/views/admin/gitaly_servers/index.html.haml b/app/views/admin/gitaly_servers/index.html.haml index d0cf5761726..9b24f411a75 100644 --- a/app/views/admin/gitaly_servers/index.html.haml +++ b/app/views/admin/gitaly_servers/index.html.haml @@ -6,10 +6,10 @@ - if @gitaly_servers.any? .table-holder %table.table.responsive-table - %thead.d-none.d-sm-none.d-md-block + %thead %tr %th= _("Storage") - %th= n_("Gitaly|Address") + %th= s_("Gitaly|Address") %th= _("Server version") %th= _("Git version") %th= _("Up to date") diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index dc4dccc9e0d..c8008771236 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -2,6 +2,9 @@ = form_errors(@group) = render 'shared/group_form', f: f + = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group + = render_if_exists 'admin/namespace_plan', f: f + .form-group.row.group-description-holder = f.label :avatar, "Group avatar", class: 'col-form-label col-sm-2' .col-sm-10 @@ -15,6 +18,8 @@ = render 'groups/group_admin_settings', f: f + = render_if_exists 'namespaces/shared_runners_minutes_settings', group: @group, form: f + - if @group.new_record? .form-group.row .offset-sm-2.col-sm-10 @@ -28,3 +33,5 @@ .form-actions = f.submit 'Save changes', class: "btn btn-save" = link_to 'Cancel', admin_group_path(@group), class: "btn btn-cancel" + += render_if_exists 'ldap_group_links/ldap_syncrhonizations', group: @group diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index e7c70a6f187..3f96988c203 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -1,3 +1,4 @@ +- group = local_assigns.fetch(:group) - css_class = 'no-description' if group.description.blank? %li.group-row{ class: css_class } @@ -8,6 +9,8 @@ %span.badge.badge-pill = storage_counter(group.storage_size) + = render_if_exists 'admin/namespace_plan_badge', namespace: group + %span = icon('bookmark') = number_with_delimiter(group.projects.count) diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 6d75ccd5add..a40f98ad24f 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -40,6 +40,8 @@ %strong = @group.created_at.to_s(:medium) + = render_if_exists 'admin/namespace_plan_info', namespace: @group + %li %span.light Storage: %strong= storage_counter(@group.storage_size) @@ -58,6 +60,10 @@ = group_lfs_status(@group) = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') + = render_if_exists 'namespaces/shared_runner_status', namespace: @group + + = render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group + .card .card-header %h3.card-title @@ -104,7 +110,7 @@ = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do %div - = users_select_tag(:user_ids, multiple: true, email_user: true, scope: :all) + = users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all) .prepend-top-10 = select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2" %hr diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index 231c0f70882..946d868da01 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -7,10 +7,10 @@ - values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] } = f.select :provider, values, { allow_blank: false }, class: 'form-control' .form-group.row - = f.label :extern_uid, "Identifier", class: 'col-form-label col-sm-2' + = f.label :extern_uid, _("Identifier"), class: 'col-form-label col-sm-2' .col-sm-10 = f.text_field :extern_uid, class: 'form-control', required: true .form-actions - = f.submit 'Save changes', class: "btn btn-save" + = f.submit _('Save changes'), class: "btn btn-save" diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml index 50fe9478a78..5ed59809db5 100644 --- a/app/views/admin/identities/_identity.html.haml +++ b/app/views/admin/identities/_identity.html.haml @@ -5,8 +5,8 @@ = identity.extern_uid %td = link_to edit_admin_user_identity_path(@user, identity), class: 'btn btn-sm btn-grouped' do - Edit + = _("Edit") = link_to [:admin, @user, identity], method: :delete, class: 'btn btn-sm btn-danger', - data: { confirm: "Are you sure you want to remove this identity?" } do - Delete + data: { confirm: _("Are you sure you want to remove this identity?") } do + = _('Delete') diff --git a/app/views/admin/identities/edit.html.haml b/app/views/admin/identities/edit.html.haml index 515d46b0f29..1ad6ce969cb 100644 --- a/app/views/admin/identities/edit.html.haml +++ b/app/views/admin/identities/edit.html.haml @@ -1,6 +1,6 @@ -- page_title "Edit", @identity.provider, "Identities", @user.name, "Users" +- page_title _("Edit"), @identity.provider, _("Identities"), @user.name, _("Users") %h3.page-title - Edit identity for #{@user.name} + = _('Edit identity for %{user_name}') % { user_name: @user.name } %hr = render 'form' diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index ee51fb3fda1..59373ee6752 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -1,15 +1,15 @@ -- page_title "Identities", @user.name, "Users" +- page_title _("Identities"), @user.name, _("Users") = render 'admin/users/head' -= link_to 'New identity', new_admin_user_identity_path, class: 'float-right btn btn-new' += link_to _('New identity'), new_admin_user_identity_path, class: 'float-right btn btn-new' - if @identities.present? .table-holder %table.table %thead %tr - %th Provider - %th Identifier + %th= _('Provider') + %th= _('Identifier') %th = render @identities - else - %h4 This user has no identities + %h4= _('This user has no identities') diff --git a/app/views/admin/identities/new.html.haml b/app/views/admin/identities/new.html.haml index e30bf0ef0ee..ee743b0fd3c 100644 --- a/app/views/admin/identities/new.html.haml +++ b/app/views/admin/identities/new.html.haml @@ -1,4 +1,4 @@ -- page_title "New Identity" -%h3.page-title New identity +- page_title _("New Identity") +%h3.page-title= _('New identity') %hr = render 'form' diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml index 7637471f9ae..ee2d4c8430a 100644 --- a/app/views/admin/labels/_form.html.haml +++ b/app/views/admin/labels/_form.html.haml @@ -10,16 +10,16 @@ .col-sm-10 = f.text_field :description, class: "form-control js-quick-submit" .form-group.row - = f.label :color, "Background color", class: 'col-form-label col-sm-2' + = f.label :color, _("Background color"), class: 'col-form-label col-sm-2' .col-sm-10 .input-group .input-group-prepend .input-group-text.label-color-preview = f.text_field :color, class: "form-control" .form-text.text-muted - Choose any color. + = _('Choose any color.') %br - Or you can choose one of the suggested colors below + = _("Or you can choose one of the suggested colors below") .suggest-colors - suggested_colors.each do |color| @@ -27,5 +27,5 @@ .form-actions - = f.submit 'Save', class: 'btn btn-save js-save-button' - = link_to "Cancel", admin_labels_path, class: 'btn btn-cancel' + = f.submit _('Save'), class: 'btn btn-save js-save-button' + = link_to _("Cancel"), admin_labels_path, class: 'btn btn-cancel' diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index 009a47dd517..c3ea2352898 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -3,5 +3,5 @@ = render_colored_label(label, tooltip: false) = markdown_field(label, :description) .float-right - = link_to 'Edit', edit_admin_label_path(label), class: 'btn btn-sm' - = link_to 'Delete', admin_label_path(label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Delete this label? Are you sure?"} + = link_to _('Edit'), edit_admin_label_path(label), class: 'btn btn-sm' + = link_to _('Delete'), admin_label_path(label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Delete this label? Are you sure?"} diff --git a/app/views/admin/labels/edit.html.haml b/app/views/admin/labels/edit.html.haml index 96f0d404ac4..652ed095d00 100644 --- a/app/views/admin/labels/edit.html.haml +++ b/app/views/admin/labels/edit.html.haml @@ -1,7 +1,7 @@ -- add_to_breadcrumbs "Labels", admin_labels_path -- breadcrumb_title "Edit Label" -- page_title "Edit", @label.name, "Labels" +- add_to_breadcrumbs _("Labels"), admin_labels_path +- breadcrumb_title _("Edit Label") +- page_title _("Edit"), @label.name, _("Labels") %h3.page-title - Edit Label + = _('Edit Label') %hr = render 'form' diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml index add38fb333e..d3e5247447a 100644 --- a/app/views/admin/labels/index.html.haml +++ b/app/views/admin/labels/index.html.haml @@ -1,10 +1,10 @@ -- page_title "Labels" +- page_title _("Labels") %div = link_to new_admin_label_path, class: "float-right btn btn-nr btn-new" do - New label + = _('New label') %h3.page-title - Labels + = _('Labels') %hr .labels @@ -14,5 +14,5 @@ = paginate @labels, theme: 'gitlab' - else .card.bg-light - .nothing-here-block There are no labels yet + .nothing-here-block= _('There are no labels yet') diff --git a/app/views/admin/labels/new.html.haml b/app/views/admin/labels/new.html.haml index 0135ad0723d..20103fb8a29 100644 --- a/app/views/admin/labels/new.html.haml +++ b/app/views/admin/labels/new.html.haml @@ -1,5 +1,5 @@ -- page_title "New Label" +- page_title _("New Label") %h3.page-title - New Label + = _('New Label') %hr = render 'form' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 0a22a142858..ccba1c461fc 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -116,8 +116,8 @@ .card-body = form_for @project, url: transfer_admin_project_path(@project), method: :put do |f| .form-group.row - = f.label :new_namespace_id, "Namespace", class: 'col-form-label col-sm-2' - .col-sm-10 + = f.label :new_namespace_id, "Namespace", class: 'col-form-label col-sm-3' + .col-sm-9 .dropdown = dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' }) .dropdown-menu.dropdown-select @@ -127,7 +127,7 @@ = dropdown_loading .form-group.row - .offset-sm-2.col-sm-10 + .offset-sm-3.col-sm-9 = f.submit 'Transfer', class: 'btn btn-primary' .card.repository-check diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index f38aeb151df..8dfd176f1b7 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -67,7 +67,7 @@ %th Projects %th Jobs %th Tags - %th= link_to 'Last contact', admin_runners_path(params.slice(:search).merge(sort: 'contacted_asc')) + %th= link_to 'Last contact', admin_runners_path(safe_params.slice(:search).merge(sort: 'contacted_asc')) %th - @runners.each do |runner| diff --git a/app/views/admin/services/_form.html.haml b/app/views/admin/services/_form.html.haml index 144dceacbdd..993006e8745 100644 --- a/app/views/admin/services/_form.html.haml +++ b/app/views/admin/services/_form.html.haml @@ -7,5 +7,4 @@ = render 'shared/service_settings', form: form, subject: @service .footer-block.row-content-block - .form-actions - = form.submit 'Save', class: 'btn btn-save' + = form.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 35a331283ab..04acc5f8423 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,26 +1,26 @@ %fieldset %legend Access - .form-group - = f.label :projects_limit, class: 'col-form-label' + .form-group.row + = f.label :projects_limit, class: 'col-form-label col-sm-2' .col-sm-10= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control' - .form-group - = f.label :can_create_group, class: 'col-form-label' + .form-group.row + = f.label :can_create_group, class: 'col-form-label col-sm-2' .col-sm-10= f.check_box :can_create_group - .form-group - = f.label :access_level, class: 'col-form-label' + .form-group.row + = f.label :access_level, class: 'col-form-label col-sm-2' .col-sm-10 - editing_current_user = (current_user == @user) = f.radio_button :access_level, :regular, disabled: editing_current_user - = label_tag :regular do + = label_tag :regular, class: 'font-weight-bold' do Regular %p.light Regular users have access to their groups and projects = f.radio_button :access_level, :admin, disabled: editing_current_user - = label_tag :admin do + = label_tag :admin, class: 'font-weight-bold' do Admin %p.light Administrators have access to all groups, projects and users and can manage all features in this installation @@ -28,8 +28,8 @@ %p.light You cannot remove your own admin rights. - .form-group - = f.label :external, class: 'col-form-label' + .form-group.row + = f.label :external, class: 'col-form-label col-sm-2' .col-sm-10 = f.check_box :external do External diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 010cb2ac354..58be07fc83e 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -56,7 +56,7 @@ = f.label :linkedin, class: 'col-form-label col-sm-2' .col-sm-10= f.text_field :linkedin, class: 'form-control' .form-group.row - = f.label :twitter, class: 'col-form-label' + = f.label :twitter, class: 'col-form-label col-sm-2' .col-sm-10= f.text_field :twitter, class: 'form-control' .form-group.row = f.label :website_url, 'Website', class: 'col-form-label col-sm-2' diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 5e176b61d68..b2163ee85fa 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -38,7 +38,7 @@ %li.divider - if user.can_be_removed? %li - %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', target: '#delete-user-modal', delete_user_url: admin_user_path(user), block_user_url: block_admin_user_path(user), @@ -47,7 +47,7 @@ = s_('AdminUsers|Delete user') %li - %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', target: '#delete-user-modal', delete_user_url: admin_user_path(user, hard_delete: true), block_user_url: block_admin_user_path(user), diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 4b3c52af16a..8ca9fb4512e 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -12,9 +12,9 @@ - if can?(current_user, :award_emoji, awardable) .award-menu-holder.js-award-holder %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', - 'aria-label': 'Add reaction', + 'aria-label': _('Add reaction'), class: ("js-user-authored" if user_authored), - data: { title: 'Add reaction', placement: "bottom" } } + data: { title: _('Add reaction'), placement: "bottom" } } %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 571eb28f195..6ee55836dd2 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -43,5 +43,6 @@ %span.toggle-icon = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') + = render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } = icon('minus-circle') diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index c45d2214592..0ee563ac066 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -12,5 +12,9 @@ %span Remember me .float-right.forgot-password = link_to "Forgot your password?", new_password_path(:user) + %div + - if captcha_enabled? + = recaptcha_tags + .submit-container.move-submit-down = f.submit "Sign in", class: "btn btn-save" diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 2554b2688bb..ee7369f54a9 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -22,6 +22,13 @@ = f.label :password = f.password_field :password, class: "form-control bottom", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters." %p.gl-field-hint Minimum length is #{@minimum_password_length} characters + - if Gitlab::CurrentSettings.current_application_settings.enforce_terms? + .form-group + = check_box_tag :terms_opt_in, '1', false, required: true + = label_tag :terms_opt_in do + - terms_link = link_to s_("I accept the|Terms of Service and Privacy Policy"), terms_path, target: "_blank" + - accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link } + = accept_terms_label.html_safe %div - if Gitlab::Recaptcha.enabled? = recaptcha_tags diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index 087af61235b..58c585a29ff 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -3,8 +3,8 @@ %li.nav-item = link_to "Crowd", "#crowd", class: 'nav-link active', 'data-toggle' => 'tab' - @ldap_servers.each_with_index do |server, i| - %li.nav-item{ class: active_when(i.zero? && !crowd_enabled?) } - = link_to server['label'], "##{server['provider_name']}", class: 'nav-link', 'data-toggle' => 'tab' + %li.nav-item + = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i.zero? && !crowd_enabled?)}", 'data-toggle' => 'tab' - if password_authentication_enabled_for_web? %li.nav-item = link_to 'Standard', '#login-pane', class: 'nav-link', 'data-toggle' => 'tab' diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 6d9c6b5572a..28cdc7607e0 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -35,7 +35,7 @@ - @pre_auth.scopes.each do |scope| %li %strong= t scope, scope: [:doorkeeper, :scopes] - .scope-description= t scope, scope: [:doorkeeper, :scope_desc] + .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] .form-actions.text-right = form_tag oauth_authorization_path, method: :delete, class: 'inline' do = hidden_field_tag :client_id, @pre_auth.client.uid diff --git a/app/views/email_rejection_mailer/rejection.html.haml b/app/views/email_rejection_mailer/rejection.html.haml index 7f7d841fe21..c4ae7befe4e 100644 --- a/app/views/email_rejection_mailer/rejection.html.haml +++ b/app/views/email_rejection_mailer/rejection.html.haml @@ -2,3 +2,4 @@ Unfortunately, your email message to GitLab could not be processed. = markdown @reason += render_if_exists 'shared/additional_email_text' diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml index af518b5b583..0e13b2a6473 100644 --- a/app/views/email_rejection_mailer/rejection.text.haml +++ b/app/views/email_rejection_mailer/rejection.text.haml @@ -1,3 +1,4 @@ Unfortunately, your email message to GitLab could not be processed. \ = @reason += render_if_exists 'shared/additional_email_text' diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml index 227c7884915..8ae29b9d337 100644 --- a/app/views/errors/access_denied.html.haml +++ b/app/views/errors/access_denied.html.haml @@ -1,4 +1,4 @@ -- message = local_assigns.fetch(:message) +- message = local_assigns.fetch(:message, nil) - content_for(:title, 'Access Denied') = image_tag('illustrations/error-403.svg', alt: '403', lazy: false) diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index 845f4046d0d..6abb56ba6d2 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -1,6 +1,6 @@ - if current_user .dropdown - %button.dropdown-toggle{ href: '#', "data-toggle" => "dropdown" } + %button.dropdown-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } = icon('globe') %span.light Visibility: - if params[:visibility_level].present? diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index fd6e7111f38..577c63503a8 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,4 +1,4 @@ -.nav-block +.nav-block.activities .controls = link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do %i.fa.fa-rss diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 6a0321bcd2b..13d584f5f1d 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -25,9 +25,10 @@ %span.badge= @members.total_count = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group - = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } - %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } - = icon("search") + .position-relative.append-right-8 + = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } + %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } + = icon("search") - if can_manage_members = render 'shared/members/filter_2fa_dropdown' = render 'shared/members/sort_dropdown' diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 8037cf4b69d..5e1ae1dbe38 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -9,7 +9,7 @@ = render 'shared/issuable/nav', type: :issues .nav-controls = render 'shared/issuable/feed_buttons' - = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues' = render 'shared/issuable/search_bar', type: :issues diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index ac7e12fcd0b..db7eaff6658 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -1,21 +1,32 @@ -- page_title 'Labels' - +- @no_container = true +- page_title "Labels" +- can_admin_label = can?(current_user, :admin_label, @group) +- hide_class = '' +- hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') - issuables = ['issues', 'merge requests'] -.top-area.adjust - .nav-text - = _("Labels can be applied to %{features}. Group labels are available for any project within the group.") % { features: issuables.to_sentence } +- if can_admin_label + - content_for(:header_content) do + .nav-controls + = link_to _('New label'), new_group_label_path(@group), class: "btn btn-new" + +- if @labels.exists? + #promote-label-modal + %div{ class: container_class } + .top-area.adjust + .nav-text + = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuables.to_sentence } - .nav-controls - - if can?(current_user, :admin_label, @group) - = link_to "New label", new_group_label_path(@group), class: "btn btn-new" + .labels-container.prepend-top-5 + .other-labels + - if can_admin_label + %h5{ class: ('hide' if hide) } Labels + %ul.content-list.manage-labels-list.js-other-labels + = render partial: 'shared/label', subject: @group, collection: @labels, as: :label, locals: { use_label_priority: false } + = paginate @labels, theme: 'gitlab' +- else + = render 'shared/empty_states/labels' -.labels - .other-labels - - if @labels.present? - %ul.content-list.manage-labels-list.js-other-labels - = render partial: 'shared/label', subject: @group, collection: @labels, as: :label - = paginate @labels, theme: 'gitlab' - - else - .nothing-here-block - = _("No labels created yet.") +%template#js-badge-item-template + %li.label-link-item.js-priority-badge.inline.prepend-left-10 + .label-badge.label-badge-blue= _('Prioritized label') diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 4ccd16f3e11..e2a317dbf67 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -7,7 +7,7 @@ = render 'shared/issuable/nav', type: :merge_requests - if current_user .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests' = render 'shared/issuable/search_bar', type: :merge_requests diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 1e72d88db1e..53f54db1ddf 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -4,27 +4,37 @@ - page_title 'New Group' - header_title "Groups", dashboard_groups_path -%h3.page-title - New Group -%hr +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = _('New group') + %p + - group_docs_path = help_page_path('user/group/index') + - group_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_docs_path } + = s_('%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.').html_safe % { group_docs_link_start: group_docs_link_start, group_docs_link_end: '</a>'.html_safe } + %p + - subgroup_docs_path = help_page_path('user/group/subgroups/index') + - subgroup_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: subgroup_docs_path } + = s_('Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}.').html_safe % { subgroup_docs_link_start: subgroup_docs_link_start, subgroup_docs_link_end: '</a>'.html_safe } -= form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f| - = form_errors(@group) - = render 'shared/group_form', f: f, autofocus: true + .col-lg-9 + = form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f| + = form_errors(@group) + = render 'shared/group_form', f: f, autofocus: true - .form-group.row.group-description-holder - = f.label :avatar, "Group avatar", class: 'col-form-label col-sm-2' - .col-sm-10 - = render 'shared/choose_group_avatar_button', f: f + .form-group.row.group-description-holder + = f.label :avatar, "Group avatar", class: 'col-form-label col-sm-2' + .col-sm-10 + = render 'shared/choose_group_avatar_button', f: f - = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group + = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group - = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled + = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled - .form-group.row - .offset-sm-2.col-sm-10 - = render 'shared/group_tips' + .form-group.row + .offset-sm-2.col-sm-10 + = render 'shared/group_tips' - .form-actions - = f.submit 'Create group', class: "btn btn-create" - = link_to 'Cancel', dashboard_groups_path, class: 'btn btn-cancel' + .form-actions + = f.submit 'Create group', class: "btn btn-create" + = link_to 'Cancel', dashboard_groups_path, class: 'btn btn-cancel' diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 383d955d71f..647948c7dff 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -7,7 +7,7 @@ .settings-header %h4 = _('Variables') - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.btn-default.js-settings-toggle{ type: "button" } = expanded ? _('Collapse') : _('Expand') %p.append-bottom-0 @@ -18,7 +18,7 @@ %section.settings#runners-settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - = _('Runners settings') + = _('Runners') %button.btn.btn-default.js-settings-toggle{ type: "button" } = expanded ? _('Collapse') : _('Expand') %p diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 8b0ef3cd87a..5a88619f769 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -18,7 +18,7 @@ - if can_create_subgroups .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } %input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } } - %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } } + %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } = icon("caret-down", class: "dropdown-btn-icon") %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } %li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } } diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 29db29235c1..c23fe0b5c49 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -18,71 +18,71 @@ %th Global Shortcuts %tr %td.shortcut - .key s + %kbd s %td Focus Search %tr %td.shortcut - .key f + %kbd f %td Focus Filter - if performance_bar_enabled? %tr %td.shortcut - .key p b + %kbd p b %td Show/hide the Performance Bar %tr %td.shortcut - .key ? + %kbd ? %td Show/hide this dialog %tr %td.shortcut - if browser.platform.mac? - .key ⌘ shift p + %kbd ⌘ shift p - else - .key ctrl shift p + %kbd ctrl shift p %td Toggle Markdown preview %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-up %td Edit last comment (when focused on an empty textarea) %tr %td.shortcut - .key shift t + %kbd shift t %td Go to todos %tr %td.shortcut - .key shift a + %kbd shift a %td Go to the activity feed %tr %td.shortcut - .key shift p + %kbd shift p %td Go to projects %tr %td.shortcut - .key shift i + %kbd shift i %td Go to issues %tr %td.shortcut - .key shift m + %kbd shift m %td Go to merge requests %tr %td.shortcut - .key shift g + %kbd shift g %td Go to groups %tr %td.shortcut - .key shift l + %kbd shift l %td Go to milestones %tr %td.shortcut - .key shift s + %kbd shift s %td Go to snippets %tbody @@ -91,21 +91,21 @@ %th Finding Project File %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-up %td Move selection up %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-down %td Move selection down %tr %td.shortcut - .key enter + %kbd enter %td Open Selection %tr %td.shortcut - .key esc + %kbd esc %td Go back .col-lg-4 %table.shortcut-mappings @@ -115,95 +115,95 @@ %th Project %tr %td.shortcut - .key g - .key p + %kbd g + %kbd p %td Go to the project's overview page %tr %td.shortcut - .key g - .key v + %kbd g + %kbd v %td Go to the project's activity feed %tr %td.shortcut - .key g - .key f + %kbd g + %kbd f %td Go to files %tr %td.shortcut - .key g - .key c + %kbd g + %kbd c %td Go to commits %tr %td.shortcut - .key g - .key j + %kbd g + %kbd j %td Go to jobs %tr %td.shortcut - .key g - .key n + %kbd g + %kbd n %td Go to network graph %tr %td.shortcut - .key g - .key d + %kbd g + %kbd d %td Go to repository charts %tr %td.shortcut - .key g - .key i + %kbd g + %kbd i %td Go to issues %tr %td.shortcut - .key g - .key b + %kbd g + %kbd b %td Go to issue boards %tr %td.shortcut - .key g - .key m + %kbd g + %kbd m %td Go to merge requests %tr %td.shortcut - .key g - .key e + %kbd g + %kbd e %td Go to environments %tr %td.shortcut - .key g - .key k + %kbd g + %kbd k %td Go to kubernetes %tr %td.shortcut - .key g - .key s + %kbd g + %kbd s %td Go to snippets %tr %td.shortcut - .key g - .key w + %kbd g + %kbd w %td Go to wiki %tr %td.shortcut - .key t + %kbd t %td Go to finding file %tr %td.shortcut - .key i + %kbd i %td New issue %tbody @@ -212,17 +212,17 @@ %th Project Files browsing %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-up %td Move selection up %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-down %td Move selection down %tr %td.shortcut - .key enter + %kbd enter %td Open Selection %tbody %tr @@ -230,7 +230,7 @@ %th Project File %tr %td.shortcut - .key y + %kbd y %td Go to file permalink %tbody %tr @@ -239,115 +239,115 @@ %tr %td.shortcut - if browser.platform.mac? - .key ⌘ p + %kbd ⌘ p - else - .key ctrl p + %kbd ctrl p %td Go to file .col-lg-4 %table.shortcut-mappings - %tbody.hidden-shortcut.network{ style: 'display:none' } + %tbody.hidden-shortcut{ style: 'display:none' } %tr %th %th Network Graph %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-left \/ - .key h + %kbd h %td Scroll left %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-right \/ - .key l + %kbd l %td Scroll right %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-up \/ - .key k + %kbd k %td Scroll up %tr %td.shortcut - .key + %kbd %i.fa.fa-arrow-down \/ - .key j + %kbd j %td Scroll down %tr %td.shortcut - .key + %kbd shift %i.fa.fa-arrow-up \/ - .key + %kbd shift k %td Scroll to top %tr %td.shortcut - .key + %kbd shift %i.fa.fa-arrow-down \/ - .key + %kbd shift j %td Scroll to bottom - %tbody.hidden-shortcut.issues{ style: 'display:none' } + %tbody.hidden-shortcut{ style: 'display:none' } %tr %th %th Issues %tr %td.shortcut - .key a + %kbd a %td Change assignee %tr %td.shortcut - .key m + %kbd m %td Change milestone %tr %td.shortcut - .key r + %kbd r %td Reply (quoting selected text) %tr %td.shortcut - .key e + %kbd e %td Edit issue %tr %td.shortcut - .key l + %kbd l %td Change Label - %tbody.hidden-shortcut.merge_requests{ style: 'display:none' } + %tbody.hidden-shortcut{ style: 'display:none' } %tr %th %th Merge Requests %tr %td.shortcut - .key a + %kbd a %td Change assignee %tr %td.shortcut - .key m + %kbd m %td Change milestone %tr %td.shortcut - .key r + %kbd r %td Reply (quoting selected text) %tr %td.shortcut - .key e + %kbd e %td Edit merge request %tr %td.shortcut - .key l + %kbd l %td Change Label - %tbody.hidden-shortcut.wiki{ style: 'display:none' } + %tbody.hidden-shortcut{ style: 'display:none' } %tr %th %th Wiki pages %tr %td.shortcut - .key e + %kbd e %td Edit wiki page diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index de8369ed7b9..b32b602ceb3 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -443,8 +443,6 @@ .col-md-6 .alert.alert-success = lorem - .alert.alert-primary - = lorem .alert.alert-info = lorem .col-md-6 diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml index eb9790c7903..581576a8a3d 100644 --- a/app/views/import/gitea/new.html.haml +++ b/app/views/import/gitea/new.html.haml @@ -12,11 +12,11 @@ = form_tag personal_access_token_import_gitea_path do .form-group.row - = label_tag :gitea_host_url, 'Gitea Host URL', class: 'col-form-label col-sm-8' + = label_tag :gitea_host_url, 'Gitea Host URL', class: 'col-form-label col-sm-2' .col-sm-4 = text_field_tag :gitea_host_url, nil, placeholder: 'https://try.gitea.io', class: 'form-control' .form-group.row - = label_tag :personal_access_token, 'Personal Access Token', class: 'col-form-label col-sm-8' + = label_tag :personal_access_token, 'Personal Access Token', class: 'col-form-label col-sm-2' .col-sm-4 = text_field_tag :personal_access_token, nil, class: 'form-control' .form-actions diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index c63cf2b31cb..b9ebb1a39d9 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -19,7 +19,7 @@ = form_tag personal_access_token_import_github_path, method: :post, class: 'form-inline' do .form-group - = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('Personal Access Token'), size: 40 + = text_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40 = submit_tag _('List your GitHub repositories'), class: 'btn btn-success' - unless github_import_configured? diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 2d059e78490..cc672a5ea7c 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -20,7 +20,7 @@ - else .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } - .input-group-text + .input-group-text.border-0 #{user_url(current_user.username)}/ = hidden_field_tag :namespace_id, value: current_user.namespace_id .form-group.col-12.col-sm-6.project-path @@ -37,6 +37,6 @@ .form-group = file_field_tag :file, class: '' .row - .form-actions + .form-actions.col-sm-12 = submit_tag 'Import project', class: 'btn btn-create' = link_to 'Cancel', new_project_path, class: 'btn btn-cancel' diff --git a/app/views/kaminari/gitlab/_first_page.html.haml b/app/views/kaminari/gitlab/_first_page.html.haml index 369165da02a..3b7d4a1c578 100644 --- a/app/views/kaminari/gitlab/_first_page.html.haml +++ b/app/views/kaminari/gitlab/_first_page.html.haml @@ -5,5 +5,5 @@ -# total_pages: total number of pages -# per_page: number of items to fetch per page -# remote: data-remote -%li.first.page-item +%li.page-item.js-first-button = link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, remote: remote, class: 'page-link' diff --git a/app/views/kaminari/gitlab/_gap.html.haml b/app/views/kaminari/gitlab/_gap.html.haml index 6eec30212d1..849f92fdc95 100644 --- a/app/views/kaminari/gitlab/_gap.html.haml +++ b/app/views/kaminari/gitlab/_gap.html.haml @@ -4,5 +4,5 @@ -# total_pages: total number of pages -# per_page: number of items to fetch per page -# remote: data-remote -%li.page-item.disabled +%li.page-item.disabled.d-none.d-md-block = link_to raw(t 'views.pagination.truncate'), '#', class: 'page-link' diff --git a/app/views/kaminari/gitlab/_last_page.html.haml b/app/views/kaminari/gitlab/_last_page.html.haml index 8b49db58281..7836e17f877 100644 --- a/app/views/kaminari/gitlab/_last_page.html.haml +++ b/app/views/kaminari/gitlab/_last_page.html.haml @@ -5,5 +5,5 @@ -# total_pages: total number of pages -# per_page: number of items to fetch per page -# remote: data-remote -%li.last.page-item +%li.page-item.js-last-button = link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, {remote: remote, class: 'page-link'} diff --git a/app/views/kaminari/gitlab/_next_page.html.haml b/app/views/kaminari/gitlab/_next_page.html.haml index 05f151555ad..a7fa1a21a6c 100644 --- a/app/views/kaminari/gitlab/_next_page.html.haml +++ b/app/views/kaminari/gitlab/_next_page.html.haml @@ -8,5 +8,5 @@ - page_url = current_page.last? ? '#' : url -%li.page-item{ class: ('disabled' if current_page.last?) } +%li.page-item.js-next-button{ class: ('disabled' if current_page.last?) } = link_to raw(t 'views.pagination.next'), page_url, rel: 'next', remote: remote, class: 'page-link' diff --git a/app/views/kaminari/gitlab/_page.html.haml b/app/views/kaminari/gitlab/_page.html.haml index 8a40e13a537..d0dc1784540 100644 --- a/app/views/kaminari/gitlab/_page.html.haml +++ b/app/views/kaminari/gitlab/_page.html.haml @@ -6,5 +6,5 @@ -# total_pages: total number of pages -# per_page: number of items to fetch per page -# remote: data-remote -%li.page-item.js-pagination-page{ class: [active_when(page.current?), ('sibling' if page.next? || page.prev?)] } +%li.page-item.js-pagination-page{ class: [active_when(page.current?), ('sibling' if page.next? || page.prev?), ('d-none d-md-block' if !page.current?) ] } = link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: 'page-link' } diff --git a/app/views/kaminari/gitlab/_paginator.html.haml b/app/views/kaminari/gitlab/_paginator.html.haml index a6435deb4bf..ac9e274dbc7 100644 --- a/app/views/kaminari/gitlab/_paginator.html.haml +++ b/app/views/kaminari/gitlab/_paginator.html.haml @@ -6,7 +6,7 @@ -# remote: data-remote -# paginator: the paginator that renders the pagination tags inside = paginator.render do - .gl-pagination + .gl-pagination.prepend-top-default %ul.pagination.justify-content-center - unless current_page.first? = first_page_tag unless total_pages < 5 # As kaminari will always show the first 5 pages diff --git a/app/views/kaminari/gitlab/_prev_page.html.haml b/app/views/kaminari/gitlab/_prev_page.html.haml index f4a11a449b7..12b0e106a62 100644 --- a/app/views/kaminari/gitlab/_prev_page.html.haml +++ b/app/views/kaminari/gitlab/_prev_page.html.haml @@ -8,5 +8,5 @@ - page_url = current_page.first? ? '#' : url -%li.page-item{ class: ('disabled' if current_page.first?) } +%li.page-item.js-previous-button{ class: ('disabled' if current_page.first?) } = link_to raw(t 'views.pagination.previous'), page_url, rel: 'prev', remote: remote, class: 'page-link' diff --git a/app/views/kaminari/gitlab/_without_count.html.haml b/app/views/kaminari/gitlab/_without_count.html.haml index 1425a809052..f780400ebcb 100644 --- a/app/views/kaminari/gitlab/_without_count.html.haml +++ b/app/views/kaminari/gitlab/_without_count.html.haml @@ -1,5 +1,5 @@ -.gl-pagination - %ul.pagination.clearfix +.gl-pagination.prepend-top-default + %ul.pagination.justify-content-center - if previous_path %li.page-item.prev = link_to(t('views.pagination.previous'), previous_path, rel: 'prev', class: 'page-link') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 02bdfe9aa3c..9253a0652da 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -25,7 +25,7 @@ %title= page_title(site_name) %meta{ name: "description", content: page_description } - = favicon_link_tag favicon, id: 'favicon' + = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 82ca7252424..81f35615555 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,7 +1,7 @@ !!! 5 %html.devise-layout-html{ class: system_message_class } = render "layouts/head" - %body.ui_indigo.login-page.application.navless{ data: { page: body_data_page } } + %body.ui-indigo.login-page.application.navless{ data: { page: body_data_page } } .page-wrap = render "layouts/header/empty" .login-page-broadcast diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index adf90cb7667..52805e0da73 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: "en", class: system_message_class } = render "layouts/head" - %body.ui_indigo.login-page.application.navless + %body.ui-indigo.login-page.application.navless = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 24b6c490a5a..a74ea246eaf 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -17,6 +17,11 @@ = link_to _("Help"), help_path - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) %li.divider + %li + = link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do + = _("Contribute to GitLab") + = sprite_icon('external-link', size: 16) + %li.divider - if current_user_menu?(:sign_out) %li = link_to _("Sign out"), destroy_user_session_path, class: "sign-out-link" diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 1bca837a311..5cec443e969 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -61,7 +61,7 @@ - if header_link?(:sign_in) %li.nav-item %div - = link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'nav-link btn btn-sign-in' + = link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in' %button.navbar-toggler.d-block.d-sm-none{ type: 'button' } %span.sr-only Toggle navigation diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index db8137cc248..d35df706036 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -1,5 +1,5 @@ %li.header-new.dropdown - = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do + = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do = sprite_icon('plus-square', size: 16) = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index fdb07ce6fc5..33416bf76d7 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -8,7 +8,7 @@ .sidebar-context-title = @project.name %ul.sidebar-top-level-items - = nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do + = nav_link(path: sidebar_projects_paths, html_options: { class: 'home' }) do = link_to project_path(@project), class: 'shortcuts-project' do .nav-icon-container = sprite_icon('project') @@ -29,13 +29,15 @@ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do %span= _('Activity') + = render_if_exists 'projects/sidebar/security_dashboard' + - if can?(current_user, :read_cycle_analytics, @project) = nav_link(path: 'cycle_analytics#show') do = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do %span= _('Cycle Analytics') - if project_nav_tab? :files - = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do + = nav_link(controller: sidebar_repository_paths) do = link_to project_tree_path(@project), class: 'shortcuts-tree' do .nav-icon-container = sprite_icon('doc_text') @@ -43,7 +45,7 @@ = _('Repository') %ul.sidebar-sub-level-items - = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network), html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: sidebar_repository_paths, html_options: { class: "fly-out-top-item" } ) do = link_to project_tree_path(@project) do %strong.fly-out-top-item-name = _('Repository') @@ -80,6 +82,8 @@ = link_to charts_project_graph_path(@project, current_ref) do = _('Charts') + = render_if_exists 'projects/sidebar/repository_locked_files' + - if project_nav_tab? :issues = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do = link_to project_issues_path(@project), class: 'shortcuts-issues' do @@ -92,7 +96,7 @@ = number_with_delimiter(@project.open_issues_count(current_user)) %ul.sidebar-sub-level-items - = nav_link(controller: :issues, html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: :issues, action: :index, html_options: { class: "fly-out-top-item" } ) do = link_to project_issues_path(@project) do %strong.fly-out-top-item-name = _('Issues') @@ -115,6 +119,8 @@ %span = _('Labels') + = render_if_exists 'projects/sidebar/issues_service_desk' + = nav_link(controller: :milestones) do = link_to project_milestones_path(@project), title: 'Milestones' do %span @@ -278,7 +284,7 @@ = _('Snippets') - if project_nav_tab? :settings - = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show]) do + = nav_link(path: sidebar_settings_paths) do = link_to edit_project_path(@project), class: 'shortcuts-tree' do .nav-icon-container = sprite_icon('settings') @@ -288,7 +294,7 @@ %ul.sidebar-sub-level-items - can_edit = can?(current_user, :admin_project, @project) - if can_edit - = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show], html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: sidebar_settings_paths, html_options: { class: "fly-out-top-item" } ) do = link_to edit_project_path(@project) do %strong.fly-out-top-item-name = _('Settings') @@ -326,6 +332,8 @@ %span = _('Pages') + = render_if_exists 'projects/sidebar/settings_audit_events' + - else = nav_link(controller: :project_members) do = link_to project_settings_members_path(@project), title: 'Members', class: 'shortcuts-tree' do diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index a8964b19ba1..977eb350365 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -14,9 +14,9 @@ %div{ class: "#{container_class} limit-container-width" } .content{ id: "content-body" } - .panel.panel-default - .panel-heading - .title + .card + .card-header + .card-title = brand_header_logo - logo_text = brand_header_logo_type - if logo_text.present? diff --git a/app/views/notify/merge_request_unmergeable_email.html.haml b/app/views/notify/merge_request_unmergeable_email.html.haml index 578fa1fbce7..7ec0c1ef390 100644 --- a/app/views/notify/merge_request_unmergeable_email.html.haml +++ b/app/views/notify/merge_request_unmergeable_email.html.haml @@ -1,6 +1,2 @@ %p - Merge Request #{link_to @merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)} can no longer be merged due to the following #{'reason'.pluralize(@reasons.count)}: - - %ul - - @reasons.each do |reason| - %li= reason + Merge Request #{link_to @merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)} can no longer be merged due to conflict. diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml index e4f9f1bf5e7..dcdd6db69d6 100644 --- a/app/views/notify/merge_request_unmergeable_email.text.haml +++ b/app/views/notify/merge_request_unmergeable_email.text.haml @@ -1,7 +1,4 @@ -Merge Request #{@merge_request.to_reference} can no longer be merged due to the following #{'reason'.pluralize(@reasons.count)}: - -- @reasons.each do |reason| - * #{reason} +Merge Request #{@merge_request.to_reference} can no longer be merged due to conflict. Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 0a9adc6f243..dd6a84e503d 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -9,6 +9,8 @@ %p Assignee: #{@merge_request.assignee_name} += render_if_exists 'notify/merge_request_approvers', merge_request: @merge_request + - if @merge_request.description %div = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index 7d98400e6fe..d5b8f8d764f 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -5,6 +5,6 @@ New Merge Request <%= @merge_request.to_reference %> <%= merge_path_description(@merge_request, 'to') %> Author: <%= @merge_request.author_name %> Assignee: <%= @merge_request.assignee_name %> +<%= render_if_exists 'notify/merge_request_approvers', merge_request: @merge_request %> <%= @merge_request.description %> - diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 6ea358d9f63..c14700794ce 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -4,10 +4,12 @@ .form-group = f.label :key, class: 'label-light' - = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: "Don't paste the private part of the SSH key. Paste the public part, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'." + %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'. Don't use your private SSH key.") + = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: 'Typically starts with "ssh-rsa …"' .form-group = f.label :title, class: 'label-light' - = f.text_field :title, class: "form-control", required: true + = f.text_field :title, class: "form-control", required: true, placeholder: 'e.g. My MacBook key' + %p.form-text.text-muted= _('Name your individual key via a title') .prepend-top-default = f.submit 'Add key', class: "btn btn-create" diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 1e206def7ee..55ca8d0ebd4 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -11,10 +11,11 @@ %h5.prepend-top-0 Add an SSH key %p.profile-settings-content - Before you can add an SSH key you need to - = link_to "generate one", help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair') - or use an - = link_to "existing key.", help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair') + - generate_link_url = help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair') + - existing_link_url = help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair') + - generate_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: generate_link_url } + - existing_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: existing_link_url } + = _('To add an SSH key you need to %{generate_link_start}generate one%{link_end} or use an %{existing_link_start}existing key%{link_end}.').html_safe % { generate_link_start: generate_link_start, existing_link_start: existing_link_start, link_end: '</a>'.html_safe } = render 'form' %hr %h5 diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index ce312943154..8f1078bd41d 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -4,18 +4,12 @@ = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| .col-lg-4.application-theme %h4.prepend-top-0 - s_('Preferences|Navigation theme') + = s_('Preferences|Navigation theme') %p Customize the appearance of the application header and navigation sidebar. .col-lg-8.application-theme - Gitlab::Themes.each do |theme| = label_tag do - .preview{ class: theme.name.downcase } - .preview-row - .quadrant.one - .quadrant.two - .preview-row - .quadrant.three - .quadrant.four + .preview{ class: theme.css_class } = f.radio_button :theme_id, theme.id, checked: Gitlab::Themes.for_user(@user).id == theme.id = theme.name diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 1e7d9444986..f4d4888bd15 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -3,7 +3,7 @@ - project = local_assigns.fetch(:project) - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-export-project{ class: ('expanded' if expanded) } .settings-header %h4 Export project diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 075badb9e56..89940512bc6 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -42,6 +42,10 @@ .project-clone-holder = render "shared/clone_panel" + - if show_xcode_link?(@project) + .project-action-button.project-xcode.inline + = render "projects/buttons/xcode_link" + - if current_user - if can?(current_user, :download_code, @project) = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index cfbd0459e3e..6f957533287 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -16,7 +16,7 @@ - else .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' } - .input-group-text + .input-group-text.border-0 #{user_url(current_user.username)}/ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.project-path.col-sm-6 diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml index 9d27f51926e..d08807b5135 100644 --- a/app/views/projects/_project_templates.html.haml +++ b/app/views/projects/_project_templates.html.haml @@ -10,16 +10,18 @@ %a.btn.btn-default{ href: template.preview, rel: 'noopener noreferrer', target: '_blank' } Preview .project-fields-form - .form-group - %label.label-light - Template - .input-group.template-input-group - .input-group-prepend - .input-group-text - .selected-icon - - Gitlab::ProjectTemplate.all.each do |template| - = custom_icon(template.logo) - .selected-template - %button.btn.btn-default.change-template{ type: "button" } Change template + .row + .form-group.col-sm-12 + %label.label-light + Template + .input-group.template-input-group + .input-group-prepend + .input-group-text + .selected-icon + - Gitlab::ProjectTemplate.all.each do |template| + = custom_icon(template.logo) + .selected-template + .input-group-append + %button.btn.btn-default.change-template{ type: "button" } Change template = render 'new_project_fields', f: f, project_name_id: "template-project-name" diff --git a/app/views/projects/_stat_anchor_list.html.haml b/app/views/projects/_stat_anchor_list.html.haml index 8bffd1396ae..15ec58289e3 100644 --- a/app/views/projects/_stat_anchor_list.html.haml +++ b/app/views/projects/_stat_anchor_list.html.haml @@ -5,4 +5,4 @@ - anchors.each do |anchor| %li.nav-item = link_to_if anchor.link, anchor.label, anchor.link, class: anchor.enabled ? 'nav-link stat-link' : "nav-link btn btn-#{anchor.class_modifier || 'missing'}" do - %span.stat-text= anchor.label + .stat-text= anchor.label diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 7ff7466e561..87b165e581a 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -9,10 +9,10 @@ .tree-holder .nav-block %ul.breadcrumb.repo-breadcrumb - %li + %li.breadcrumb-item = link_to 'Artifacts', browse_project_job_artifacts_path(@project, @build) - path_breadcrumbs do |title, path| - %li + %li.breadcrumb-item = link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path) .tree-controls diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 1b150ec3e5c..0a0b3ce1d6f 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -11,6 +11,7 @@ = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< + = render_if_exists 'projects/blob/header_file_locks_link' = edit_blob_button = ide_edit_button - if current_user @@ -18,3 +19,4 @@ = delete_blob_link = render 'projects/fork_suggestion' += render_if_exists 'projects/blob/header_file_locks', project: @project, path: @path diff --git a/app/views/projects/blob/viewers/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml index f9b1da05a00..fda4b9c92cd 100644 --- a/app/views/projects/blob/viewers/_download.html.haml +++ b/app/views/projects/blob/viewers/_download.html.haml @@ -1,5 +1,5 @@ .file-content.blob_file.blob-no-preview - .center.render-error.vertical-center + .center.render-error = link_to blob_raw_path do %h1.light = sprite_icon('download') diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index f641d7bc51a..88f9b7dfc9f 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -72,7 +72,7 @@ - else %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", disabled: true, - title: s_('Branches|Only a project master or owner can delete a protected branch') } + title: s_('Branches|Only a project maintainer or owner can delete a protected branch') } = icon("trash-o") - else = link_to project_branch_path(@project, branch.name), diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index c75093c4c24..f7551434d47 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -3,7 +3,7 @@ - if !project.empty_repo? && can?(current_user, :download_code, project) - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" .project-action-button.dropdown.inline> - %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download') } + %button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static' } = sprite_icon('download') = icon("caret-down") %span.sr-only= _('Select Archive Format') diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 84245d72f4a..8b9c52f0802 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -8,7 +8,7 @@ - if show_menu .project-action-button.dropdown.inline - %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...') } + %a.btn.dropdown-toggle.has-tooltip{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...'), 'data-display' => 'static' } = icon('plus') = icon("caret-down") %ul.dropdown-menu.dropdown-menu-right.project-home-dropdown diff --git a/app/views/projects/buttons/_xcode_link.html.haml b/app/views/projects/buttons/_xcode_link.html.haml new file mode 100644 index 00000000000..a8b32fb0ef5 --- /dev/null +++ b/app/views/projects/buttons/_xcode_link.html.haml @@ -0,0 +1,2 @@ +%a.btn.btn-default{ href: xcode_uri_to_repo(@project) } + = _("Open in Xcode") diff --git a/app/views/projects/clusters/_advanced_settings.html.haml b/app/views/projects/clusters/_advanced_settings.html.haml index e9bdc54364b..243e8cd9ba0 100644 --- a/app/views/projects/clusters/_advanced_settings.html.haml +++ b/app/views/projects/clusters/_advanced_settings.html.haml @@ -7,8 +7,8 @@ - link_gke = link_to(s_('ClusterIntegration|Google Kubernetes Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke } - .card.form-group - %label.text-danger + .sub-section.form-group + %h4.text-danger = s_('ClusterIntegration|Remove Kubernetes cluster integration') %p = s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.") diff --git a/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml b/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml index d0402197821..9298d93663d 100644 --- a/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/projects/clusters/_gcp_signup_offer_banner.html.haml @@ -6,7 +6,7 @@ = image_tag 'illustrations/logos/google-cloud-platform_logo.svg' .col-sm-10 %h4= s_('ClusterIntegration|Redeem up to $500 in free credit for Google Cloud Platform') - %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for new GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } + %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } %a.btn.btn-info{ href: 'https://goo.gl/AaJzRW', target: '_blank', rel: 'noopener noreferrer' } Apply for credit diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/projects/clusters/_integration_form.html.haml index db97203a2aa..b46b45fea49 100644 --- a/app/views/projects/clusters/_integration_form.html.haml +++ b/app/views/projects/clusters/_integration_form.html.haml @@ -1,6 +1,6 @@ = form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| = form_errors(@cluster) - .form-group.append-bottom-20 + .form-group %h5= s_('ClusterIntegration|Integration status') %p - if @cluster.enabled? @@ -10,7 +10,7 @@ = s_('ClusterIntegration|Kubernetes cluster integration is enabled for this project.') - else = s_('ClusterIntegration|Kubernetes cluster integration is disabled for this project.') - %label.append-bottom-10.js-cluster-enable-toggle-area + %label.append-bottom-0.js-cluster-enable-toggle-area %button{ type: 'button', class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}", "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"), @@ -20,19 +20,26 @@ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') - .form-group - %h5= s_('ClusterIntegration|Security') - %p - = s_("ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application.") - = link_to s_("ClusterIntegration|Learn more about security configuration"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications') - - .form-group - %h5= s_('ClusterIntegration|Environment scope') - %p - = s_("ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster.") - = link_to s_("ClusterIntegration|Learn more about environments"), help_page_path('ci/environments') - = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + - if has_multiple_clusters?(@project) + .form-group + %h5= s_('ClusterIntegration|Environment scope') + %p + = s_("ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster.") + = link_to s_("ClusterIntegration|Learn more about environments"), help_page_path('ci/environments') + = field.text_field :environment_scope, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') - if can?(current_user, :update_cluster, @cluster) .form-group = field.submit _('Save changes'), class: 'btn btn-success' + + - unless has_multiple_clusters?(@project) + %h5= s_('ClusterIntegration|Environment scope') + %p + %code * + is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. + = link_to 'More information', ('https://docs.gitlab.com/ee/user/project/clusters/#setting-the-environment-scope') + + %h5= s_('ClusterIntegration|Security') + %p + = s_("ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application.") + = link_to s_("ClusterIntegration|Learn more about security configuration"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications') diff --git a/app/views/projects/clusters/_sidebar.html.haml b/app/views/projects/clusters/_sidebar.html.haml index 73cd7c50922..3d10348212f 100644 --- a/app/views/projects/clusters/_sidebar.html.haml +++ b/app/views/projects/clusters/_sidebar.html.haml @@ -1,7 +1,9 @@ +- clusters_help_url = help_page_path('user/project/clusters/index.md') +- help_link_start = "<a href=\"%{url}\" target=\"_blank\" rel=\"noopener noreferrer\">".html_safe +- help_link_end = '</a>'.html_safe %h4.prepend-top-0 = s_('ClusterIntegration|Kubernetes cluster integration') %p = s_('ClusterIntegration|With a Kubernetes cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.') %p - - link = link_to(_('Kubernetes'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') - = s_('ClusterIntegration|Learn more about %{link_to_documentation}').html_safe % { link_to_documentation: link } + = s_('ClusterIntegration|Learn more about %{help_link_start}Kubernetes%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: clusters_help_url }, help_link_end: help_link_end } diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml index ca7a6d5a886..b8e40b0a38b 100644 --- a/app/views/projects/clusters/gcp/_form.html.haml +++ b/app/views/projects/clusters/gcp/_form.html.haml @@ -1,4 +1,10 @@ = javascript_include_tag 'https://apis.google.com/js/api.js' +- external_link_icon = icon('external-link') +- zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones' +- machine_type_link_url = 'https://cloud.google.com/compute/docs/machine-types' +- pricing_link_url = 'https://cloud.google.com/compute/pricing#machinetype' +- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe +- help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon } %p - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') @@ -7,15 +13,15 @@ = form_for @cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| = form_errors(@cluster) .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name') + = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-light' = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') .form-group - = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-light' = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| .form-group - = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') + = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), class: 'label-light' .js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } } = provider_gcp_field.hidden_field :gcp_project_id .dropdown @@ -26,8 +32,7 @@ %span.form-text.text-muted .form-group - = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone') - = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') + = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone'), class: 'label-light' .js-gcp-zone-dropdown-entry-point = provider_gcp_field.hidden_field :zone .dropdown @@ -35,13 +40,15 @@ %span.dropdown-toggle-text = _('Select project to choose zone') = icon('chevron-down') + %p.form-text.text-muted + = s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end } .form-group - = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes') + = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes'), class: 'label-light' = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3' .form-group - = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type') + = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type'), class: 'label-light' .js-gcp-machine-type-dropdown-entry-point = provider_gcp_field.hidden_field :machine_type .dropdown @@ -49,6 +56,8 @@ %span.dropdown-toggle-text = _('Select project and zone to choose machine type') = icon('chevron-down') + %p.form-text.text-muted + = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } .form-group = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true diff --git a/app/views/projects/clusters/gcp/login.html.haml b/app/views/projects/clusters/gcp/login.html.haml index 55a42ac4847..96c7a648676 100644 --- a/app/views/projects/clusters/gcp/login.html.haml +++ b/app/views/projects/clusters/gcp/login.html.haml @@ -16,5 +16,6 @@ = _('or') = link_to('create a new Google account', 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral', target: '_blank', rel: 'noopener noreferrer') - else - - link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer') - = s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link } + .settings-message.text-center + - link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer') + = s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link } diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml index 2e92524ce8f..d45ae6ec91f 100644 --- a/app/views/projects/clusters/user/_form.html.haml +++ b/app/views/projects/clusters/user/_form.html.haml @@ -1,27 +1,28 @@ = form_for @cluster, url: user_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| = form_errors(@cluster) .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name') + = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-light' = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') - .form-group - = field.label :environment_scope, s_('ClusterIntegration|Environment scope') - = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + - if has_multiple_clusters?(@project) + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-light' + = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') + = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-light' = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL') .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') + = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-light' = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)') .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token') + = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-light' = platform_kubernetes_field.text_field :token, class: 'form-control', placeholder: s_('ClusterIntegration|Service token'), autocomplete: 'off' .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') + = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-light' = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') .form-group diff --git a/app/views/projects/clusters/user/_show.html.haml b/app/views/projects/clusters/user/_show.html.haml index 77d7a055474..4d117f435dc 100644 --- a/app/views/projects/clusters/user/_show.html.haml +++ b/app/views/projects/clusters/user/_show.html.haml @@ -1,20 +1,20 @@ = form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| = form_errors(@cluster) .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name') + = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-light' = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') + = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-light' = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL') .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') + = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-light' = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)') .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token') + = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-light' .input-group = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token', type: 'password', placeholder: s_('ClusterIntegration|Token'), autocomplete: 'off' %span.input-group-append.clipboard-addon @@ -23,7 +23,7 @@ = s_('ClusterIntegration|Show') .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') + = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-light' = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') .form-group diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index 30605927fd1..3d97e93c9e9 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -20,18 +20,18 @@ %span{ "aria-hidden": true } × .modal-body - if description - %p.append-bottom-20= description + %p= description = form_tag [type.underscore, @project.namespace.becomes(Namespace), @project, commit], method: :post, remote: false, class: "js-#{type}-form js-requires-input" do - .form-group.row.branch - = label_tag 'start_branch', branch_label, class: 'col-form-label col-sm-2' - .col-sm-10 - = hidden_field_tag :start_branch, @project.default_branch, id: 'start_branch' - = dropdown_tag(@project.default_branch, options: { title: s_("BranchSwitcherTitle|Switch branch"), filter: true, placeholder: s_("BranchSwitcherPlaceholder|Search branches"), toggle_class: 'js-project-refs-dropdown dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "start_branch", selected: @project.default_branch, start_branch: @project.default_branch, refs_url: project_branches_path(@project), submit_form_on_click: false } }) + .form-group.branch + = label_tag 'start_branch', branch_label, class: 'label-light' - - if can?(current_user, :push_code, @project) - = render 'shared/new_merge_request_checkbox' - - else - = hidden_field_tag 'create_merge_request', 1, id: nil + = hidden_field_tag :start_branch, @project.default_branch, id: 'start_branch' + = dropdown_tag(@project.default_branch, options: { title: s_("BranchSwitcherTitle|Switch branch"), filter: true, placeholder: s_("BranchSwitcherPlaceholder|Search branches"), toggle_class: 'js-project-refs-dropdown dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "start_branch", selected: @project.default_branch, start_branch: @project.default_branch, refs_url: project_branches_path(@project), submit_form_on_click: false } }) + + - if can?(current_user, :push_code, @project) + = render 'shared/new_merge_request_checkbox' + - else + = hidden_field_tag 'create_merge_request', 1, id: nil .form-actions = submit_tag label, class: 'btn btn-create' = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal" diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml index a91e31afc2b..0b8e5105bc0 100644 --- a/app/views/projects/commit/branches.html.haml +++ b/app/views/projects/commit/branches.html.haml @@ -6,7 +6,7 @@ - if @branches.any? || @tags.any? || @tags_limit_exceeded %span - = link_to "#", class: "js-details-expand label label-gray ref-name" do + = link_to "#", class: "js-details-expand badge badge-gray ref-name" do = sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle') %span.js-details-content.hide = commit_branches_links(@project, @branches) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 12b27eb9b66..90e55fd0fb0 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -34,7 +34,8 @@ .d-block.d-sm-none = render_commit_status(commit, ref: ref) - if commit.description? - %button.text-expander.d-none.d-sm-inline-block.js-toggle-button{ type: "button" } ... + %button.text-expander.js-toggle-button + = sprite_icon('ellipsis_h', size: 12) .commiter - commit_author_link = commit_author_link(commit, avatar: false, size: 24) diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 6af57d3ab26..fb1ea471dec 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -1,5 +1,5 @@ - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.qa-deploy-keys-settings.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Deploy Keys diff --git a/app/views/projects/deploy_tokens/_index.html.haml b/app/views/projects/deploy_tokens/_index.html.haml index 50e5950ced4..33faab0c510 100644 --- a/app/views/projects/deploy_tokens/_index.html.haml +++ b/app/views/projects/deploy_tokens/_index.html.haml @@ -1,6 +1,6 @@ - expanded = expand_deploy_tokens_section?(@new_deploy_token) -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded) } .settings-header %h4= s_('DeployTokens|Deploy Tokens') %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } @@ -10,9 +10,8 @@ .settings-content - if @new_deploy_token.persisted? = render 'projects/deploy_tokens/new_deploy_token', deploy_token: @new_deploy_token - - else - %h5.prepend-top-0 - = s_('DeployTokens|Add a deploy token') - = render 'projects/deploy_tokens/form', project: @project, token: @new_deploy_token, presenter: @deploy_tokens - %hr + %h5.prepend-top-0 + = s_('DeployTokens|Add a deploy token') + = render 'projects/deploy_tokens/form', project: @project, token: @new_deploy_token, presenter: @deploy_tokens + %hr = render 'projects/deploy_tokens/table', project: @project, active_tokens: @deploy_tokens diff --git a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml index 1e715681e59..5dd9ffba074 100644 --- a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml +++ b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml @@ -1,14 +1,18 @@ -.created-deploy-token-container - %h5.prepend-top-0 - = s_('DeployTokens|Your New Deploy Token') +.created-deploy-token-container.info-well + .well-segment + %h5.prepend-top-0 + = s_('DeployTokens|Your New Deploy Token') - .form-group - = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus' - = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left') - %span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.") + .form-group + .input-group + = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus' + .input-group-append + = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left') + %span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.") - .form-group - = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus' - = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left') - %span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.") -%hr + .form-group + .input-group + = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus' + .input-group-append + = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left') + %span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.") diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index c7ac687e4a6..282566eeadc 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -14,4 +14,4 @@ = author_avatar(deployment.commit, size: 20) = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message" - else - Cant find HEAD commit for this branch + = _("Can't find HEAD commit for this branch") diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 520696b01c6..85bc8ec07e3 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -1,14 +1,14 @@ .gl-responsive-table-row.deployment{ role: 'row' } .table-section.section-10{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' } ID + .table-mobile-header{ role: 'rowheader' }= _("ID") %strong.table-mobile-content ##{deployment.iid} .table-section.section-30{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' } Commit + .table-mobile-header{ role: 'rowheader' }= _("Commit") = render 'projects/deployments/commit', deployment: deployment .table-section.section-25.build-column{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' } Job + .table-mobile-header{ role: 'rowheader' }= _("Job") - if deployment.deployable .table-mobile-content .flex-truncate-parent @@ -21,7 +21,7 @@ = user_avatar(user: deployment.user, size: 20) .table-section.section-15{ role: 'gridcell' } - .table-mobile-header{ role: 'rowheader' } Created + .table-mobile-header{ role: 'rowheader' }= _("Created") %span.table-mobile-content= time_ago_with_tooltip(deployment.created_at) .table-section.section-20.table-button-footer{ role: 'gridcell' } diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml index 5941e01c6f1..95f950948ab 100644 --- a/app/views/projects/deployments/_rollback.haml +++ b/app/views/projects/deployments/_rollback.haml @@ -1,6 +1,6 @@ - if can?(current_user, :create_deployment, deployment) && deployment.deployable = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build' do - if deployment.last? - Re-deploy + = _("Re-deploy") - else - Rollback + = _("Rollback") diff --git a/app/views/projects/diffs/_collapsed.html.haml b/app/views/projects/diffs/_collapsed.html.haml index 5762f4d86d7..9bd1255fe00 100644 --- a/app/views/projects/diffs/_collapsed.html.haml +++ b/app/views/projects/diffs/_collapsed.html.haml @@ -2,4 +2,4 @@ - url = url_for(safe_params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier)) .nothing-here-block.diff-collapsed{ data: { diff_for_path: url } } This diff is collapsed. - %a.click-to-expand Click to expand it. + %button.click-to-expand.btn.btn-link Click to expand it. diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 77ba422e87e..c2d900cbcf7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -4,10 +4,10 @@ - expanded = Rails.env.test? .project-edit-container - %section.settings.general-settings.no-animate{ class: ('expanded' if expanded) } + %section.settings.general-settings.no-animate#js-general-project-settings{ class: ('expanded' if expanded) } .settings-header %h4 - General project settings + General project %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p @@ -65,7 +65,7 @@ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted" = f.submit 'Save changes', class: "btn btn-success js-btn-save-general-project-settings" - %section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) } + %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) } .settings-header %h4 Permissions @@ -82,10 +82,10 @@ = render_if_exists 'projects/issues_settings' - %section.settings.merge-requests-feature.no-animate{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } + %section.qa-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } .settings-header %h4 - Merge request settings + Merge request %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p @@ -101,10 +101,10 @@ = render 'export', project: @project - %section.settings.advanced-settings.no-animate{ class: ('expanded' if expanded) } + %section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4 - Advanced settings + Advanced %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index bf6fc8af12d..d47dc3d8143 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,3 +1,4 @@ +- @content_class = "limit-container-width" unless fluid_layout - @no_container = true - breadcrumb_title _("Details") @@ -6,7 +7,7 @@ = render "home_panel" .project-empty-note-panel - %div{ class: [container_class, ("limit-container-width-sm" unless fluid_layout)] } + %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } .prepend-top-20 %h4 = _('The repository for this project is empty') @@ -36,7 +37,7 @@ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons - if can?(current_user, :push_code, @project) - %div{ class: [container_class, ("limit-container-width-sm" unless fluid_layout)] } + %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } .prepend-top-20 .empty_wrapper %h3#repo-command-line-instructions.page-title-empty @@ -44,14 +45,14 @@ .git-empty %fieldset %h5 Git global setup - %pre.card.bg-light + %pre.bg-light :preserve git config --global user.name "#{h git_user_name}" git config --global user.email "#{h git_user_email}" %fieldset %h5 Create a new repository - %pre.card.bg-light + %pre.bg-light :preserve git clone #{ content_tag(:span, default_url_to_repo, class: 'clone')} cd #{h @project.path} @@ -64,7 +65,7 @@ %fieldset %h5 Existing folder - %pre.card.bg-light + %pre.bg-light :preserve cd existing_folder git init @@ -77,7 +78,7 @@ %fieldset %h5 Existing Git repository - %pre.card.bg-light + %pre.bg-light :preserve cd existing_repo git remote rename origin old-origin diff --git a/app/views/projects/environments/_external_url.html.haml b/app/views/projects/environments/_external_url.html.haml index a82ef5ee5bb..a264252e095 100644 --- a/app/views/projects/environments/_external_url.html.haml +++ b/app/views/projects/environments/_external_url.html.haml @@ -1,4 +1,4 @@ - if environment.external_url && can?(current_user, :read_environment, environment) = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url' do - = icon('external-link') + = sprite_icon('external-link') View deployment diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml index b4102fcf103..a4b27575095 100644 --- a/app/views/projects/environments/_metrics_button.html.haml +++ b/app/views/projects/environments/_metrics_button.html.haml @@ -3,5 +3,5 @@ - return unless can?(current_user, :read_environment, environment) = link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do - = icon('area-chart') + = sprite_icon('chart') Monitoring diff --git a/app/views/projects/environments/_terminal_button.html.haml b/app/views/projects/environments/_terminal_button.html.haml index a6201bdbc42..38bc087664b 100644 --- a/app/views/projects/environments/_terminal_button.html.haml +++ b/app/views/projects/environments/_terminal_button.html.haml @@ -1,3 +1,3 @@ - if environment.has_terminals? && can?(current_user, :admin_environment, @project) = link_to terminal_project_environment_path(@project, environment), class: 'btn terminal-button' do - = icon('terminal') + = sprite_icon('terminal') diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml index 6ec4ff56552..5b680189bc8 100644 --- a/app/views/projects/environments/terminal.html.haml +++ b/app/views/projects/environments/terminal.html.haml @@ -16,7 +16,7 @@ .nav-controls - if @environment.external_url.present? = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('external-link') + = sprite_icon('external-link') = render 'projects/deployments/actions', deployment: @environment.last_deployment .terminal-container{ class: container_class } diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 983cb187c2f..3f1974d05f4 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -30,7 +30,7 @@ #{@commits_graph.start_date.strftime('%b %d')} - end_time = capture do #{@commits_graph.end_date.strftime('%b %d')} - = (_("Commit statistics for %{ref} %{start_time} - %{end_time}") % { ref: "<strong>#{@ref}</strong>", start_time: start_time, end_time: end_time }).html_safe + = (_("Commit statistics for %{ref} %{start_time} - %{end_time}") % { ref: "<strong>#{h @ref}</strong>", start_time: start_time, end_time: end_time }).html_safe .col-md-6 .tree-ref-container diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 816f2fa816d..665968a64e1 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -8,5 +8,6 @@ %section.js-vue-notes-event #js-vue-notes{ data: { notes_data: notes_data(@issue), noteable_data: serialize_issuable(@issue), - noteable_type: 'issue', + noteable_type: 'Issue', + target_type: 'issue', current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 76438fae663..a678cb6f058 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -18,7 +18,7 @@ %button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } } = value - %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } } + %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } } = icon('caret-down') .droplab-dropdown @@ -40,7 +40,7 @@ %label{ for: 'new-branch-name' } = _('Branch name') %input#new-branch-name.js-branch-name.form-control{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" } - %span.js-branch-message.form-text.text-muted + %span.js-branch-message.form-text .form-group %label{ for: 'source-name' } diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index ec9a04c0eab..1f33bb3a129 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -86,9 +86,7 @@ %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true } = custom_icon('scroll_down') - %pre.build-trace#build-trace - %code.bash.js-build-output - .build-loader-animation.js-build-refresh + = render 'shared/builds/build_output' - else = render "empty_states" diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 9c78bade254..fb5b0fc15c9 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -1,40 +1,44 @@ - @no_container = true - page_title "Labels" -- hide_class = '' - can_admin_label = can?(current_user, :admin_label, @project) +- hide_class = '' + +- if can_admin_label + - content_for(:header_content) do + .nav-controls + = link_to _('New label'), new_project_label_path(@project), class: "btn btn-new" - if @labels.exists? || @prioritized_labels.exists? #promote-label-modal %div{ class: container_class } .top-area.adjust .nav-text - Labels can be applied to issues and merge requests. + = _('Labels can be applied to issues and merge requests.') - if can_admin_label - Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging. + = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.') - - if can_admin_label - .nav-controls - = link_to new_project_label_path(@project), class: "btn btn-new" do - New label - - .labels + .labels-container.prepend-top-5 - if can_admin_label -# Only show it in the first page - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') .prioritized-labels{ class: ('hide' if hide) } - %h5 Prioritized Labels - %ul.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) } - #js-priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty?}" } + %h5.prepend-top-10= _('Prioritized Labels') + .content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) } + #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty?}" } = render 'shared/empty_states/priority_labels' - if @prioritized_labels.present? - = render partial: 'shared/label', subject: @project, collection: @prioritized_labels, as: :label + = render partial: 'shared/label', subject: @project, collection: @prioritized_labels, as: :label, locals: { force_priority: true } - if @labels.present? .other-labels - if can_admin_label - %h5{ class: ('hide' if hide) } Other Labels - %ul.content-list.manage-labels-list.js-other-labels + %h5{ class: ('hide' if hide) }= _('Other Labels') + .content-list.manage-labels-list.js-other-labels = render partial: 'shared/label', subject: @project, collection: @labels, as: :label = paginate @labels, theme: 'gitlab' - else = render 'shared/empty_states/labels' + +%template#js-badge-item-template + %li.label-link-item.js-priority-badge.inline.prepend-left-10 + .label-badge.label-badge-blue= _('Prioritized label') diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index ebcd99f2a9b..f7a5d85500f 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -24,23 +24,28 @@ There are no commits yet. = custom_icon ('illustration_no_commits') - else - %ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom - %li.commits-tab.active - = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do - Commits - %span.badge.badge-pill= @commits.size - - if @pipelines.any? - %li.builds-tab - = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do - Pipelines - %span.badge.badge-pill= @pipelines.size - %li.diffs-tab - = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do - Changes - %span.badge.badge-pill= @merge_request.diff_size + .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } + .merge-request-tabs-container + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + %ul.merge-request-tabs.nav.nav-tabs.nav-links.no-top.no-bottom.js-tabs-affix + %li.commits-tab.new-tab + = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do + Commits + %span.badge.badge-pill= @commits.size + - if @pipelines.any? + %li.builds-tab + = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tabvue'} do + Pipelines + %span.badge.badge-pill= @pipelines.size + %li.diffs-tab + = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue'} do + Changes + %span.badge.badge-pill= @merge_request.diff_size - .tab-content - #commits.commits.tab-pane.active + #diff-notes-app.tab-content + #new.commits.tab-pane.active = render "projects/merge_requests/commits" #diffs.diffs.tab-pane -# This tab is always loaded via AJAX diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml index 19659fe5140..bf3df0abf86 100644 --- a/app/views/projects/merge_requests/diffs/_diffs.html.haml +++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml @@ -16,6 +16,6 @@ %span.ref-name= @merge_request.target_branch .text-center= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-save' - else - - diff_viewable = @merge_request_diff ? @merge_request_diff.collected? || @merge_request_diff.overflow? : true + - diff_viewable = @merge_request_diff ? @merge_request_diff.viewable? : true - if diff_viewable = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true diff --git a/app/views/projects/merge_requests/diffs/_version_controls.html.haml b/app/views/projects/merge_requests/diffs/_version_controls.html.haml index 1c26f0405d2..52bf584d550 100644 --- a/app/views/projects/merge_requests/diffs/_version_controls.html.haml +++ b/app/views/projects/merge_requests/diffs/_version_controls.html.haml @@ -3,7 +3,7 @@ .mr-version-menus-container.content-block Changes between %span.dropdown.inline.mr-version-dropdown - %a.dropdown-toggle.btn.btn-default{ data: {toggle: :dropdown} } + %a.dropdown-toggle.btn.btn-default{ data: { toggle: :dropdown, display: 'static' } } %span - if @merge_request_diff.latest? latest version @@ -36,7 +36,7 @@ - if @merge_request_diff.base_commit_sha and %span.dropdown.inline.mr-version-compare-dropdown - %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} } + %a.btn.btn-default.dropdown-toggle{ data: { toggle: :dropdown, display: 'static' } } - if @start_version version #{version_index(@start_version)} - else diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 01e38ffee20..b23baa22d8b 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -32,45 +32,27 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= icon('angle-left') .fade-right= icon('angle-right') - .nav-links.scrolling-tabs.nav.nav-tabs - %ul.merge-request-tabs.nav-tabs.nav - %li.notes-tab - = tab_link_for @merge_request, :show, force_link: @commit.present? do - Discussion - %span.badge.badge-pill= @merge_request.related_notes.user.count - - if @merge_request.source_project - %li.commits-tab - = tab_link_for @merge_request, :commits do - Commits - %span.badge.badge-pill= @commits_count - - if @pipelines.any? - %li.pipelines-tab - = tab_link_for @merge_request, :pipelines do - Pipelines - %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size - %li.diffs-tab - = tab_link_for @merge_request, :diffs do - Changes - %span.badge.badge-pill= @merge_request.diff_size + %ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs + %li.notes-tab + = tab_link_for @merge_request, :show, force_link: @commit.present? do + Discussion + %span.badge.badge-pill= @merge_request.related_notes.user.count + - if @merge_request.source_project + %li.commits-tab + = tab_link_for @merge_request, :commits do + Commits + %span.badge.badge-pill= @commits_count + - if @pipelines.any? + %li.pipelines-tab + = tab_link_for @merge_request, :pipelines do + Pipelines + %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size + %li.diffs-tab + = tab_link_for @merge_request, :diffs do + Changes + %span.badge.badge-pill= @merge_request.diff_size - - if has_vue_discussions_cookie? - #js-vue-discussion-counter - - else - #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } - %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } - %div - .line-resolve-all{ "v-show" => "discussionCount > 0", - ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } - %span.line-resolve-btn.is-disabled{ type: "button", - ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } - %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' } - = render 'shared/icons/icon_status_success_solid.svg' - %template{ 'v-else' => '' } - = render 'shared/icons/icon_resolve_discussion.svg' - %span.line-resolve-text - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved - = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request - = render "discussions/jump_to_next" + #js-vue-discussion-counter .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes @@ -78,20 +60,21 @@ %section.col-md-12 %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe .issuable-discussion.js-vue-notes-event - = render "projects/merge_requests/discussion" - - if has_vue_discussions_cookie? - #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request), - noteable_data: serialize_issuable(@merge_request), - noteable_type: 'merge_request', - current_user_data: UserSerializer.new.represent(current_user).to_json} } + #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request), + noteable_data: serialize_issuable(@merge_request), + noteable_type: 'MergeRequest', + target_type: 'merge_request', + current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} } #commits.commits.tab-pane -# This tab is always loaded via AJAX #pipelines.pipelines.tab-pane - if @pipelines.any? = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) - #diffs.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked? } } - -# This tab is always loaded via AJAX + #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?, + endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters), + current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json, + project_path: project_path(@merge_request.project)} } .mr-loading-status = spinner diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b478fbbb15e..f7b04c436a6 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -69,6 +69,8 @@ .wiki = markdown_field(@milestone, :description) + = render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project + - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? .alert.alert-success.prepend-top-default %span Assign some issues to this milestone. diff --git a/app/views/projects/mirrors/_push.html.haml b/app/views/projects/mirrors/_push.html.haml index c3dcd9617a6..2b2871a81e5 100644 --- a/app/views/projects/mirrors/_push.html.haml +++ b/app/views/projects/mirrors/_push.html.haml @@ -1,5 +1,5 @@ - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded) } .settings-header %h4 Push to a remote repository diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 35a09f06bfa..5bb1bfb7059 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -29,16 +29,16 @@ .col-lg-9.js-toggle-container %ul.nav.nav-tabs.nav-links.gitlab-tabs{ role: 'tablist' } - %li{ class: active_when(active_tab == 'blank'), role: 'presentation' } - %a{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab' }, role: 'tab' } + %li.nav-item{ role: 'presentation' } + %a.nav-link.active{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab' }, role: 'tab' } %span.d-none.d-sm-block Blank project %span.d-block.d-sm-none Blank - %li{ class: active_when(active_tab == 'template'), role: 'presentation' } - %a{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab' }, role: 'tab' } + %li.nav-item{ role: 'presentation' } + %a.nav-link{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab' }, role: 'tab' } %span.d-none.d-sm-block Create from template %span.d-block.d-sm-none Template - %li{ class: active_when(active_tab == 'import'), role: 'presentation' } - %a{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab' }, role: 'tab' } + %li.nav-item{ role: 'presentation' } + %a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab' }, role: 'tab' } %span.d-none.d-sm-block Import project %span.d-block.d-sm-none Import diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml index 4ada19a1368..9b77c4e3494 100644 --- a/app/views/projects/pages/_destroy.haml +++ b/app/views/projects/pages/_destroy.haml @@ -5,7 +5,7 @@ .errors-holder .card-body %p - Removing the pages will prevent from exposing them to outside world. + Removing pages will prevent them from being exposed to the outside world. .form-actions = link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove" - else diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index aa53fc3ea28..04131a90a57 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -29,7 +29,7 @@ = link_to @commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha js-details-short" = link_to("#", class: "js-details-expand d-none d-sm-none d-md-inline") do %span.text-expander - \... + = sprite_icon('ellipsis_h', size: 12) %span.js-details-content.hide = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full" = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 118391aac64..951f80b378d 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,7 +1,7 @@ .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %li.js-pipeline-tab-link - = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do + = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do = _("Pipeline") %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do @@ -43,12 +43,36 @@ = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage - if @pipeline.failed_builds.present? - #js-tab-failures.build-failures.tab-pane - - @pipeline.failed_builds.each_with_index do |build, index| - .build-state - %span.ci-status-icon-failed= custom_icon('icon_status_failed') - %span.stage - = build.stage.titleize - %span.build-name - = link_to build.name, pipeline_job_url(pipeline, build) - %pre.build-log= build_summary(build, skip: index >= 10) + #js-tab-failures.build-failures.tab-pane.build-page + %table.table.responsive-table.ci-table.responsive-table-sm-rounded + %thead + %th.table-th-transparent + %th.table-th-transparent= _("Name") + %th.table-th-transparent= _("Stage") + %th.table-th-transparent= _("Failure") + + %tbody + - @pipeline.failed_builds.each_with_index do |build, index| + - job = build.present(current_user: current_user) + %tr.build-state.responsive-table-border-start + %td.responsive-table-cell.ci-status-icon-failed{ data: { column: "Status"} } + .d-none.d-md-block.build-icon + = custom_icon("icon_status_#{build.status}") + .d-md-none.build-badge + = render "ci/status/badge", link: false, status: job.detailed_status(current_user) + %td.responsive-table-cell.build-name{ data: { column: _("Name")} } + = link_to build.name, pipeline_job_url(pipeline, build) + %td.responsive-table-cell.build-stage{ data: { column: _("Stage")} } + = build.stage.titleize + %td.responsive-table-cell.build-failure{ data: { column: _("Failure")} } + = build.present.callout_failure_message + %td.responsive-table-cell.build-actions + = link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build' do + = icon('repeat') + %tr.build-trace-row.responsive-table-border-end + %td + %td.responsive-table-cell.build-trace-container{ colspan: 4 } + %pre.build-trace.build-trace-rounded + %code.bash.js-build-output + = build_summary(build) + diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml index 128f52ff648..3f05e06b0c6 100644 --- a/app/views/projects/project_members/_groups.html.haml +++ b/app/views/projects/project_members/_groups.html.haml @@ -3,5 +3,5 @@ Groups with access to %strong= @project.name %span.badge.badge-pill= group_links.size - %ul.content-list + %ul.content-list.members-list = render partial: 'shared/members/group', collection: group_links, as: :group_link diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index a30870a241c..0c5a187f208 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -9,9 +9,10 @@ %span.badge.badge-pill= members.total_count = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group - = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } - %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } - = icon("search") + .position-relative + = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } + %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } + = icon("search") = render 'shared/members/sort_dropdown' %ul.content-list.members-list = render partial: 'shared/members/member', collection: members, as: :member diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index a56023e98cd..9716322f8a1 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -12,17 +12,17 @@ - else %p Members can be added by project - %i Masters + %i Maintainers or %i Owners .light - if can?(current_user, :admin_project_member, @project) %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } - %li.active{ role: 'presentation' } - %a{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member + %li.nav-tab{ role: 'presentation' } + %a.nav-link.active{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member - if @project.allowed_to_share_with_group? - %li{ role: 'presentation' } - %a{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group + %li.nav-tab{ role: 'presentation' } + %a.nav-link{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group .tab-content.gitlab-tab-content .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' } diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index a2cd7752fc4..9a06eca89bb 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -1,7 +1,7 @@ .protected-branches-list.js-protected-branches-list.qa-protected-branches-list - if @protected_branches.empty? - .card-header - %h3.card-title + .card-header.bg-white + %h3.card-title.mb-0 Protected branch (#{@protected_branches_count}) %p.settings-message.text-center There are currently no protected branches, protect a branch with the form above. diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index fd5c1aa342a..4f1c6c92484 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.qa-protected-branches-settings.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 Protected Branches @@ -12,8 +12,8 @@ %p By default, protected branches are designed to: %ul - %li prevent their creation, if not already created, from everybody except Masters - %li prevent pushes from everybody except Masters + %li prevent their creation, if not already created, from everybody except Maintainers + %li prevent pushes from everybody except Maintainers %li prevent <strong>anyone</strong> from force pushing to the branch %li prevent <strong>anyone</strong> from deleting the branch %p Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches")} and #{link_to "project permissions", help_page_path("user/permissions")}. diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index c33723d8072..9a50a51e4be 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = Rails.env.test? -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) } .settings-header %h4 Protected Tags @@ -12,7 +12,7 @@ %p By default, protected tags are designed to: %ul - %li Prevent tag creation by everybody except Masters + %li Prevent tag creation by everybody except Maintainers %li Prevent <strong>anyone</strong> from updating the tag %li Prevent <strong>anyone</strong> from deleting the tag diff --git a/app/views/projects/refs/logs_tree.js.haml b/app/views/projects/refs/logs_tree.js.haml index d07bb661615..506bf54b3f8 100644 --- a/app/views/projects/refs/logs_tree.js.haml +++ b/app/views/projects/refs/logs_tree.js.haml @@ -8,6 +8,8 @@ row.find("td.tree-time-ago").html('#{escape_javascript time_ago_with_tooltip(commit.committed_date)}'); row.find("td.tree-commit").html('#{escape_javascript render("projects/tree/tree_commit_column", commit: commit)}'); + = render_if_exists 'projects/refs/logs_tree_lock_label', lock_label: content_data[:lock_label] + - if @more_log_url :plain if($('#tree-slider').length) { diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index b4787032966..d6f758608a0 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -13,7 +13,7 @@ = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), html: { class: 'common-note-form release-form js-quick-submit' }) do |f| = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." + = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" = render 'shared/notes/hints' .error-alert .prepend-top-default diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index dfed0553f84..86de71c732b 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -26,9 +26,9 @@ - if can?(current_user, :admin_pipeline, @project.group) - group_link = link_to _('Group CI/CD settings'), group_settings_ci_cd_path(@project.group) - = _('Group masters can register group runners in the %{link}').html_safe % { link: group_link } + = _('Group maintainers can register group runners in the %{link}').html_safe % { link: group_link } - else - = _('Ask your group master to setup a group Runner.') + = _('Ask your group maintainer to setup a group Runner.') - else %h4.underlined-title diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml index acbab8b85c9..16e48814578 100644 --- a/app/views/projects/services/_index.html.haml +++ b/app/views/projects/services/_index.html.haml @@ -8,7 +8,7 @@ %colgroup %col %col - %col.d-none.d-sm-block + %col %col{ width: "120" } %thead %tr diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 209b9c71390..9314804c5dd 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -6,13 +6,13 @@ 1. = link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do Enable custom slash commands - = icon('external-link') + = sprite_icon('external-link', size: 16) on your Mattermost installation %li 2. = link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noopener noreferrer nofollow' do Add a slash command - = icon('external-link') + = sprite_icon('external-link', size: 16) in your Mattermost team with these options: %hr diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index 62bef77be97..f51dd581d29 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -1,18 +1,19 @@ - enabled = Gitlab.config.mattermost.enabled -.card - %p - This service allows users to perform common operations on this - project by entering slash commands in Mattermost. - = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do - View documentation - = icon('external-link') - %p.inline - See list of available commands in Mattermost after setting up this service, - by entering - %kbd.inline /<trigger> help - - unless enabled || @service.template? - = render 'projects/services/mattermost_slash_commands/detailed_help', subject: @service +.info-well + .well-segment + %p + This service allows users to perform common operations on this + project by entering slash commands in Mattermost. + = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do + View documentation + = sprite_icon('external-link', size: 16) + %p.inline + See list of available commands in Mattermost after setting up this service, + by entering + %kbd.inline /<trigger> help + - unless enabled || @service.template? + = render 'projects/services/mattermost_slash_commands/detailed_help', subject: @service - if enabled && !@service.template? = render 'projects/services/mattermost_slash_commands/installation_info', subject: @service diff --git a/app/views/projects/services/prometheus/_configuration_banner.html.haml b/app/views/projects/services/prometheus/_configuration_banner.html.haml index 2cc2a6b2b5b..898b55e4b39 100644 --- a/app/views/projects/services/prometheus/_configuration_banner.html.haml +++ b/app/views/projects/services/prometheus/_configuration_banner.html.haml @@ -2,7 +2,7 @@ = s_('PrometheusService|Auto configuration') - if service.manual_configuration? - .well + .info-well = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below') - else .container-fluid diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml index 15e7362c2ba..35d655e4b32 100644 --- a/app/views/projects/services/prometheus/_help.html.haml +++ b/app/views/projects/services/prometheus/_help.html.haml @@ -5,5 +5,5 @@ = s_('PrometheusService|Manual configuration') - unless @service.editable? - .card + .info-well = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters') diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 44f58ad05e5..f25d2ecdfb1 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -1,99 +1,100 @@ - pretty_name = defined?(@project) ? @project.full_name : 'namespace / path' - run_actions_text = "Perform common operations on GitLab project: #{pretty_name}" -.card - %p - This service allows users to perform common operations on this - project by entering slash commands in Slack. - = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do - View documentation - = icon('external-link') - %p.inline - See list of available commands in Slack after setting up this service, - by entering - %kbd.inline /<command> help - - unless @service.template? - %p To setup this service: - %ul.list-unstyled.indent-list - %li - 1. - = link_to 'https://my.slack.com/services/new/slash-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do - Add a slash command - = icon('external-link') - in your Slack team with these options: +.info-well + .well-segment + %p + This service allows users to perform common operations on this + project by entering slash commands in Slack. + = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do + View documentation + = sprite_icon('external-link', size: 16) + %p.inline + See list of available commands in Slack after setting up this service, + by entering + %kbd.inline /<command> help + - unless @service.template? + %p To setup this service: + %ul.list-unstyled.indent-list + %li + 1. + = link_to 'https://my.slack.com/services/new/slash-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do + Add a slash command + = sprite_icon('external-link', size: 16) + in your Slack team with these options: - %hr + %hr - .help-form - .form-group - = label_tag nil, 'Command', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block - %p Fill in the word that works best for your team. - %p - Suggestions: - %code= 'gitlab' - %code= @project.path # Path contains no spaces, but dashes - %code= @project.full_path + .help-form + .form-group + = label_tag nil, 'Command', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.text-block + %p Fill in the word that works best for your team. + %p + Suggestions: + %code= 'gitlab' + %code= @project.path # Path contains no spaces, but dashes + %code= @project.full_path - .form-group - = label_tag :url, 'URL', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group - = text_field_tag :url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' - .input-group-append - = clipboard_button(target: '#url', class: 'input-group-text') + .form-group + = label_tag :url, 'URL', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.input-group + = text_field_tag :url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly' + .input-group-append + = clipboard_button(target: '#url', class: 'input-group-text') - .form-group - = label_tag nil, 'Method', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block POST + .form-group + = label_tag nil, 'Method', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.text-block POST - .form-group - = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group - = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' - .input-group-append - = clipboard_button(target: '#customize_name', class: 'input-group-text') + .form-group + = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.input-group + = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' + .input-group-append + = clipboard_button(target: '#customize_name', class: 'input-group-text') - .form-group - = label_tag nil, 'Customize icon', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block - = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36) - = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer') + .form-group + = label_tag nil, 'Customize icon', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.text-block + = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36) + = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer') - .form-group - = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.text-block Show this command in the autocomplete list + .form-group + = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.text-block Show this command in the autocomplete list - .form-group - = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group - = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' - .input-group-append - = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') + .form-group + = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.input-group + = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' + .input-group-append + = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') - .form-group - = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group - = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' - .input-group-append - = clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') + .form-group + = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.input-group + = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' + .input-group-append + = clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text') - .form-group - = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-12 col-form-label' - .col-sm-10.col-12.input-group - = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control form-control-sm', readonly: 'readonly' - .input-group-append - = clipboard_button(target: '#descriptive_label', class: 'input-group-text') + .form-group + = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-12 col-form-label' + .col-sm-10.col-12.input-group + = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control form-control-sm', readonly: 'readonly' + .input-group-append + = clipboard_button(target: '#descriptive_label', class: 'input-group-text') - %hr + %hr - %ul.list-unstyled.indent-list - %li - 2. Paste the - %strong Token - into the field below - %li - 3. Select the - %strong Active - checkbox, press - %strong Save changes - and start using GitLab inside Slack! + %ul.list-unstyled.indent-list + %li + 2. Paste the + %strong Token + into the field below + %li + 3. Select the + %strong Active + checkbox, press + %strong Save changes + and start using GitLab inside Slack! diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index bbabb98dafe..31c2616d283 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -1,43 +1,66 @@ -.row.prepend-top-default +.row .col-lg-12 = form_for @project, url: project_settings_ci_cd_path(@project) do |f| = form_errors(@project) - %fieldset.builds-feature + %fieldset.builds-feature.js-auto-devops-settings .form-group - message = auto_devops_warning_message(@project) - ci_file_formatted = '<code>.gitlab-ci.yml</code>'.html_safe - if message - %p.settings-message.text-center + %p.auto-devops-warning-message.settings-message.text-center = message.html_safe = f.fields_for :auto_devops_attributes, @auto_devops do |form| - .form-check - = form.radio_button :enabled, 'true', class: 'form-check-input' - = form.label :enabled_true, class: 'form-check-label' do - %strong= s_('CICD|Enable Auto DevOps') - %br - = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted } + .card.auto-devops-card + .card-body + .form-check + = form.radio_button :enabled, 'true', class: 'form-check-input js-toggle-extra-settings' + = form.label :enabled_true, class: 'form-check-label' do + %strong= s_('CICD|Enable Auto DevOps') + .form-text.text-muted + = s_('CICD|The Auto DevOps pipeline configuration will be used when there is no %{ci_file} in the project.').html_safe % { ci_file: ci_file_formatted } - .form-check - = form.radio_button :enabled, 'false', class: 'form-check-input' - = form.label :enabled_false, class: 'form-check-label' do - %strong= s_('CICD|Disable Auto DevOps') - %br - = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted } + .card.auto-devops-card + .card-body + .form-check + = form.radio_button :enabled, '', class: 'form-check-input js-toggle-extra-settings' + = form.label :enabled_, class: 'form-check-label' do + %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" } + .form-text.text-muted + = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted } - .form-check - = form.radio_button :enabled, '', class: 'form-check-input' - = form.label :enabled_, class: 'form-check-label' do - %strong= s_('CICD|Instance default (%{state})') % { state: "#{Gitlab::CurrentSettings.auto_devops_enabled? ? _('enabled') : _('disabled')}" } - %br - = s_('CICD|Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific %{ci_file}.').html_safe % { ci_file: ci_file_formatted } + .card.auto-devops-card.js-extra-settings{ class: form.object&.enabled == false ? 'hidden' : nil } + .card-body.bg-light + = form.label :domain do + %strong= _('Domain') + = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' + .form-text.text-muted + = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.') + - if cluster_ingress_ip = cluster_ingress_ip(@project) + = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe } + = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank' - = form.label :domain, class:"prepend-top-10" do - = _('Domain') - = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' - .form-text.text-muted - = s_('CICD|A domain is required to use Auto Review Apps and Auto Deploy Stages.') - - if cluster_ingress_ip = cluster_ingress_ip(@project) - = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe } - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank' + %label.prepend-top-10 + %strong= s_('CICD|Deployment strategy') + %p.settings-message.text-center + = s_('CICD|Deployment strategy needs a domain name to work correctly.') + .form-check + = form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input' + = form.label :deploy_strategy_continuous, class: 'form-check-label' do + %strong= s_('CICD|Continuous deployment to production') + = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-deploy'), target: '_blank' + .form-check + = form.radio_button :deploy_strategy, 'manual', class: 'form-check-input' + = form.label :deploy_strategy_manual, class: 'form-check-label' do + %strong= s_('CICD|Automatic deployment to staging, manual deployment to production') + = link_to icon('question-circle'), help_page_path('ci/environments.md', anchor: 'manually-deploying-to-environments'), target: '_blank' - = f.submit 'Save changes', class: "btn btn-success prepend-top-15" + .card.auto-devops-card + .card-body + .form-check + = form.radio_button :enabled, 'false', class: 'form-check-input js-toggle-extra-settings', data: { hide_extra_settings: true } + = form.label :enabled_false, class: 'form-check-label' do + %strong= s_('CICD|Disable Auto DevOps') + .form-text.text-muted + = s_('CICD|An explicit %{ci_file} needs to be specified before you can begin using Continuous Integration and Delivery.').html_safe % { ci_file: ci_file_formatted } + + = f.submit _('Save changes'), class: "btn btn-success prepend-top-15" diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index e93b240a007..5025460a2d0 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -4,20 +4,20 @@ = form_errors(@project) %fieldset.builds-feature .form-group.append-bottom-default.js-secret-runner-token - = f.label :runners_token, "Runner token", class: 'label-light' + = f.label :runners_token, _("Runner token"), class: 'label-light' .form-control.js-secret-value-placeholder = '*' * 20 - = f.text_field :runners_token, class: "form-control hidden js-secret-value", placeholder: 'xEeFCaDAB89' - %p.form-text.text-muted The secure token used by the Runner to checkout the project + = f.text_field :runners_token, class: "form-control hide js-secret-value", placeholder: 'xEeFCaDAB89' + %p.form-text.text-muted= _("The secure token used by the Runner to checkout the project") %button.btn.btn-info.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: 'false' } } = _('Reveal value') %hr .form-group %h5.prepend-top-0 - Git strategy for pipelines + = _("Git strategy for pipelines") %p - Choose between <code>clone</code> or <code>fetch</code> to get the recent application code + = _("Choose between <code>clone</code> or <code>fetch</code> to get the recent application code") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank' .form-check = f.radio_button :build_allow_git_fetch, 'false', { class: 'form-check-input' } @@ -25,29 +25,29 @@ %strong git clone %br %span.descr - Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job + = _("Slower but makes sure the project workspace is pristine as it clones the repository from scratch for every job") .form-check = f.radio_button :build_allow_git_fetch, 'true', { class: 'form-check-input' } = f.label :build_allow_git_fetch_true, class: 'form-check-label' do %strong git fetch %br %span.descr - Faster as it re-uses the project workspace (falling back to clone if it doesn't exist) + = _("Faster as it re-uses the project workspace (falling back to clone if it doesn't exist)") %hr .form-group - = f.label :build_timeout_human_readable, 'Timeout', class: 'label-light' + = f.label :build_timeout_human_readable, _('Timeout'), class: 'label-light' = f.text_field :build_timeout_human_readable, class: 'form-control' %p.form-text.text-muted - Per job. If a job passes this threshold, it will be marked as failed + = _("Per job. If a job passes this threshold, it will be marked as failed") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr .form-group - = f.label :ci_config_path, 'Custom CI config path', class: 'label-light' + = f.label :ci_config_path, _('Custom CI config path'), class: 'label-light' = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted - The path to CI config file. Defaults to <code>.gitlab-ci.yml</code> + = _("The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' %hr @@ -55,36 +55,35 @@ .form-check = f.check_box :public_builds, { class: 'form-check-input' } = f.label :public_builds, class: 'form-check-label' do - %strong Public pipelines + %strong= _("Public pipelines") .form-text.text-muted - Allow public access to pipelines and job details, including output logs and artifacts + = _("Allow public access to pipelines and job details, including output logs and artifacts") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank' .bs-callout.bs-callout-info - %p If enabled: + %p #{_("If enabled")}: %ul %li - For public projects, anyone can view pipelines and access job details (output logs and artifacts) + = _("For public projects, anyone can view pipelines and access job details (output logs and artifacts)") %li - For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts) + = _("For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)") %li - For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts) + = _("For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)") %p - If disabled, the access level will depend on the user's - permissions in the project. + = _("If disabled, the access level will depend on the user's permissions in the project.") %hr .form-group .form-check = f.check_box :auto_cancel_pending_pipelines, { class: 'form-check-input' }, 'enabled', 'disabled' = f.label :auto_cancel_pending_pipelines, class: 'form-check-label' do - %strong Auto-cancel redundant, pending pipelines + %strong= _("Auto-cancel redundant, pending pipelines") .form-text.text-muted - New pipelines will cancel older, pending pipelines on the same branch + = _("New pipelines will cancel older, pending pipelines on the same branch") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank' %hr .form-group - = f.label :build_coverage_regex, "Test coverage parsing", class: 'label-light' + = f.label :build_coverage_regex, _("Test coverage parsing"), class: 'label-light' .input-group %span.input-group-prepend .input-group-text / @@ -92,11 +91,10 @@ %span.input-group-append .input-group-text / %p.form-text.text-muted - A regular expression that will be used to find the test coverage - output in the job trace. Leave blank to disable + = _("A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank' .bs-callout.bs-callout-info - %p Below are examples of regex for existing tools: + %p= _("Below are examples of regex for existing tools:") %ul %li Simplecov (Ruby) - @@ -120,7 +118,7 @@ JaCoCo (Java/Kotlin) %code Total.*?([0-9]{1,3})% - = f.submit 'Save changes', class: "btn btn-save" + = f.submit _('Save changes'), class: "btn btn-save" %hr diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index ed17bd4f7dc..be22bbd7a9b 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -1,6 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "CI / CD Settings" -- page_title "CI / CD" +- page_title _("CI / CD Settings") +- page_title _("CI / CD") - expanded = Rails.env.test? - general_expanded = @project.errors.empty? ? expanded : true @@ -8,15 +8,15 @@ %section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } .settings-header %h4 - General pipelines settings + = _("General pipelines") %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p - Access your runner token, customize your pipeline configuration, and view your pipeline status and coverage report. + = _("Access your runner token, customize your pipeline configuration, and view your pipeline status and coverage report.") .settings-content = render 'form' -%section.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded) } +%section.qa-autodevops-settings.settings#autodevops-settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 = s_('CICD|Auto DevOps') @@ -28,38 +28,36 @@ .settings-content = render 'autodevops_form' -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.qa-runners-settings.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - Runners settings + = _("Runners") %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p - Register and see your runners for this project. + = _("Register and see your runners for this project.") .settings-content = render 'projects/runners/index' -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.qa-variables-settings.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 = _('Variables') - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p.append-bottom-0 = render "ci/variables/content" .settings-content = render 'ci/variables/index', save_endpoint: project_variables_path(@project) -%section.settings.no-animate{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-pipeline-triggers{ class: ('expanded' if expanded) } .settings-header %h4 - Pipeline triggers + = _("Pipeline triggers") %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p - Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will - impersonate their associated user including their access to projects and their project - permissions. + = _("Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions.") .settings-content = render 'projects/triggers/index' diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml index 77d88aed883..ef445f2e139 100644 --- a/app/views/projects/settings/integrations/_project_hook.html.haml +++ b/app/views/projects/settings/integrations/_project_hook.html.haml @@ -8,9 +8,9 @@ %span.badge.badge-gray.deploy-project-label= event.to_s.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 %span.append-right-10.inline - SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} - = link_to 'Edit', edit_project_hook_path(@project, hook), class: 'btn btn-sm' + #{_("SSL Verification")}: #{hook.enable_ssl_verification ? _('enabled') : _('disabled')} + = link_to _('Edit'), edit_project_hook_path(@project, hook), class: 'btn btn-sm' = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: hook, button_class: 'btn-sm' - = link_to project_hook_path(@project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-transparent' do - %span.sr-only Remove + = link_to project_hook_path(@project, hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-transparent' do + %span.sr-only= _("Remove") = icon('trash') diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index 2f1a548e119..76770290f36 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -1,5 +1,5 @@ - @content_class = "limit-container-width" unless fluid_layout -- breadcrumb_title "Integrations Settings" -- page_title 'Integrations' +- breadcrumb_title _("Integrations Settings") +- page_title _('Integrations') = render 'projects/hooks/index' = render 'projects/services/index' diff --git a/app/views/projects/settings/members/show.html.haml b/app/views/projects/settings/members/show.html.haml index ea2cd36b212..5fca734222b 100644 --- a/app/views/projects/settings/members/show.html.haml +++ b/app/views/projects/settings/members/show.html.haml @@ -1,5 +1,5 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "Members" +- page_title _("Members") = render "projects/project_members/index" diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 5dda2ec28b4..98c609d7bd4 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title "Repository Settings" -- page_title "Repository" +- breadcrumb_title _("Repository Settings") +- page_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout = render "projects/mirrors/show" diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index b596748ca5f..da822ac5675 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -36,7 +36,7 @@ = label_tag :release_description, s_('TagsPage|Release notes'), class: 'col-form-label col-sm-2' .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here...'), current_text: @release_description + = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description = render 'shared/notes/hints' .form-text.text-muted = s_('TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.') diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 4fc1a284693..9d196075bf1 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -6,7 +6,7 @@ = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - if on_top_of_branch? - - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' } + - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' } - else - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index bcceb69954a..26fe1de31fe 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -25,7 +25,7 @@ .col-sm-12= f.label :content, class: 'control-label-full-width' .col-sm-12 = render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do - = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here...") + = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here…") = render 'shared/notes/hints' .clearfix diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 35c7dc2984a..d80d2957466 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -1,4 +1,4 @@ -- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout +- @content_class = "limit-container-width" unless fluid_layout - page_title _("Edit"), @page.title.capitalize, _("Wiki") = wiki_page_errors(@error) diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index 909987d8090..8c2cbd495a0 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -1,4 +1,4 @@ -- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout +- @content_class = "limit-container-width" unless fluid_layout - page_title s_("WikiClone|Git Access"), _("Wiki") .wiki-page-header.has-sidebar-toggle diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index ff72c8bb75d..a08973c7f32 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -1,4 +1,4 @@ -- @content_class = "limit-container-width limit-container-width-sm" unless fluid_layout +- @content_class = "limit-container-width" unless fluid_layout - breadcrumb_title @page.title.capitalize - wiki_breadcrumb_dropdown_links(@page.slug) - page_title @page.title.capitalize, _("Wiki") diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index de473c23d66..fdcd126e7a3 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,13 +1,5 @@ -- file_name, blob = blob -.blob-result - .file-holder - .js-file-title.file-title - - ref = @search_results.repository_ref - - blob_link = project_blob_path(@project, tree_join(ref, file_name)) - = link_to blob_link do - %i.fa.fa-file - %strong - = file_name - - if blob - .file-content.code.term - = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline, blob_link: blob_link +- project = find_project_for_result_blob(blob) +- file_name, blob = parse_search_result(blob) +- blob_link = project_blob_path(project, tree_join(blob.ref, file_name)) + += render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: file_name, blob_link: blob_link } diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml new file mode 100644 index 00000000000..143e9f91ca3 --- /dev/null +++ b/app/views/search/results/_blob_data.html.haml @@ -0,0 +1,10 @@ +.blob-result + .file-holder + .js-file-title.file-title + = link_to blob_link do + %i.fa.fa-file + %strong + = search_blob_title(project, file_name) + - if blob.data + .file-content.code.term + = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline diff --git a/app/views/search/results/_commit.html.haml b/app/views/search/results/_commit.html.haml index f34eaf89027..ed5a3badf11 100644 --- a/app/views/search/results/_commit.html.haml +++ b/app/views/search/results/_commit.html.haml @@ -1 +1 @@ -= render 'projects/commits/commit', project: @project, commit: commit, ref: nil += render 'projects/commits/commit', project: commit.project, commit: commit, ref: nil, show_project_name: @project.nil? diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 16a0e432d62..4346217c230 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,10 +1,5 @@ -- wiki_blob = parse_search_result(wiki_blob) -.blob-result - .file-holder - .js-file-title.file-title - = link_to project_wiki_path(@project, wiki_blob.basename) do - %i.fa.fa-file - %strong - = wiki_blob.basename - .file-content.code.term - = render 'shared/file_highlight', blob: wiki_blob, first_line_number: wiki_blob.startline +- project = find_project_for_result_blob(wiki_blob) +- file_name, wiki_blob = parse_search_result(wiki_blob) +- wiki_blob_link = project_wiki_path(project, wiki_blob.basename) + += render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: file_name, blob_link: wiki_blob_link } diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 5fc02ba3160..3655c2a1d42 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -11,7 +11,7 @@ %span = default_clone_protocol.upcase = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown + %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown %li = ssh_clone_button(project) %li diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml index 0c8d90d92f5..b89045e726a 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -9,11 +9,11 @@ - help = field[:help] - disabled = disable_fields_service?(@service) -.form-group +.form-group.row - if type == "password" && value.present? - = form.label name, "Enter new #{title.downcase}", class: "col-form-label" + = form.label name, "Enter new #{title.downcase}", class: "col-form-label col-sm-2" - else - = form.label name, title, class: "col-form-label" + = form.label name, title, class: "col-form-label col-sm-2" .col-sm-10 - if type == 'text' = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index ba5b65a209d..e93925b5ef9 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -1,93 +1,72 @@ - label_css_id = dom_id(label) - status = label_subscription_status(label, @project).inquiry if current_user - subject = local_assigns[:subject] +- use_label_priority = local_assigns.fetch(:use_label_priority, false) +- force_priority = local_assigns.fetch(:force_priority, use_label_priority ? label.priority.present? : false) - toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user - show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project) - show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project) +- tooltip_title = label_status_tooltip(label, status) if status %li.label-list-item{ id: label_css_id, data: { id: label.id } } - = render "shared/label_row", label: label - - .d-inline-block.d-sm-none.dropdown - %button.btn.btn-default.label-options-toggle{ type: 'button', data: { toggle: "dropdown" } } - Options - = icon('caret-down') - .dropdown-menu.dropdown-menu-right - %ul - - if show_label_merge_requests_link - %li - = link_to_label(label, subject: subject, type: :merge_request) do - View merge requests - - if show_label_issues_link - %li - = link_to_label(label, subject: subject) do - View open issues - - if current_user - %li.label-subscription - - if can_subscribe_to_label_in_different_levels?(label) - %a.js-unsubscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path } } - %span Unsubscribe - %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_project_label_path(@project, label) } } - %span Subscribe at project level - %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } } - %span Subscribe at group level - - else - %a.js-subscribe-button.label-subscribe-button{ role: 'button', href: '#', data: { status: status, url: toggle_subscription_path } } - %span= label_subscription_toggle_button_text(label, @project) - - - if can?(current_user, :admin_label, label) - %li - = link_to 'Edit', edit_label_path(label) - %li - = link_to 'Delete', - destroy_label_path(label), - title: 'Delete', - method: :delete, - data: {confirm: 'Remove this label? Are you sure?'}, - class: 'text-danger' - - .float-right.d-none.d-sm-none.d-md-block + = render "shared/label_row", label: label, subject: subject, force_priority: force_priority + %ul.label-actions-list + - if @project + %li.inline + .label-badge.label-badge-gray= label.model_name.human.capitalize + - if can?(current_user, :admin_label, @project) + %li.inline.js-toggle-priority{ data: { url: remove_priority_project_label_path(@project, label), + dom_id: dom_id(label), type: label.type } } + %button.label-action.add-priority.btn.btn-transparent.has-tooltip{ title: _('Prioritize'), type: 'button', data: { placement: 'top' }, aria_label: _('Prioritize label') } + = sprite_icon('star-o') + %button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'top' }, aria_label: _('Deprioritize label') } + = sprite_icon('star') - if can?(current_user, :admin_label, label) - - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) - %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'), - disabled: true, - type: 'button', - data: { url: promote_project_label_path(label.project, label), - label_title: label.title, - label_color: label.color, - label_text_color: label.text_color, - group_name: label.project.group.name, - target: '#promote-label-modal', - container: 'body', - toggle: 'modal' } } - = sprite_icon('level-up') - = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do - %span.sr-only Edit - = sprite_icon('pencil') - %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } } - = link_to "#", title: "Delete", class: 'btn btn-transparent btn-action remove-row', data: { toggle: "tooltip" } do - %span.sr-only Delete - = sprite_icon('remove') + %li.inline + = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit', aria_label: 'Edit label' do + = sprite_icon('pencil') + %li.inline + .dropdown + %button{ type: 'button', class: 'btn btn-transparent js-label-options-dropdown label-action', data: { toggle: 'dropdown' }, aria_label: _('Label actions dropdown') } + = sprite_icon('ellipsis_v') + .dropdown-menu.dropdown-open-left + %ul + - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) + %li + %button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button', + data: { url: promote_project_label_path(label.project, label), + label_title: label.title, + label_color: label.color, + label_text_color: label.text_color, + group_name: label.project.group.name, + target: '#promote-label-modal', + container: 'body', + toggle: 'modal' } } + = _('Promote to group label') + - if can?(current_user, :admin_label, label) + %li + %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } } + %button.text-danger.remove-row{ type: 'button' }= _('Delete') - if current_user - .label-subscription.inline + %li.inline.label-subscription - if can_subscribe_to_label_in_different_levels?(label) - %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path } } - %span Unsubscribe - = icon('spinner spin', class: 'label-subscribe-button-loading') - + %button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } + %span= _('Unsubscribe') .dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) } - %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span Subscribe - = icon('chevron-down') - %ul.dropdown-menu - %li - %a.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_project_label_path(@project, label) } } - Project level - %a.js-subscribe-button{ class: ('hidden' unless status.unsubscribed?), data: { url: toggle_subscription_group_label_path(label.group, label) } } - Group level + %button.label-subscribe-button.btn.btn-default{ data: { toggle: 'dropdown' } } + %span + = _('Subscribe') + = sprite_icon('chevron-down') + .dropdown-menu.dropdown-open-left + %ul + %li + %button.js-subscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_project_label_path(@project, label) } } + %span= _('Subscribe at project level') + %li + %button.js-subscribe-button.js-group-level.label-subscribe-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } } + %span= _('Subscribe at group level') - else - %button.js-subscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', data: { status: status, url: toggle_subscription_path } } + %button.js-subscribe-button.label-subscribe-button.btn.btn-default{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title } %span= label_subscription_toggle_button_text(label, @project) - = icon('spinner spin', class: 'label-subscribe-button-loading') = render 'shared/delete_label_modal', label: label diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index f1c1ca9b2c9..0ae3ab8f090 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -1,30 +1,23 @@ - subject = local_assigns[:subject] +- force_priority = local_assigns.fetch(:force_priority, false) - show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project) - show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project) -%span.label-row - - if can?(current_user, :admin_label, @project) - .draggable-handler - = icon('bars') - .js-toggle-priority.toggle-priority{ data: { url: remove_priority_project_label_path(@project, label), - dom_id: dom_id(label), type: label.type } } - %button.add-priority.btn.has-tooltip{ title: 'Prioritize', type: 'button', :'data-placement' => 'top' } - = icon('star-o') - %button.remove-priority.btn.has-tooltip{ title: 'Remove priority', type: 'button', :'data-placement' => 'top' } - = icon('star') - %span.label-name - = link_to_label(label, subject: @project, tooltip: false) - - if defined?(@project) && @project.group.present? - %span.label-type - = label.model_name.human.titleize - - %span.label-description +.label-name + = link_to_label(label, subject: @project, tooltip: false) +.label-description + .append-right-default.prepend-left-default - if label.description.present? - .description-text + .description-text.append-bottom-10 = markdown_field(label, :description) - .d-none.d-sm-none.d-md-block + %ul.label-links - if show_label_issues_link - = link_to_label(label, subject: subject) { 'Issues' } + %li.label-link-item.inline + = link_to_label(label, subject: subject) { 'Issues' } - if show_label_merge_requests_link · - = link_to_label(label, subject: subject, type: :merge_request) { 'Merge requests' } + %li.label-link-item.inline + = link_to_label(label, subject: subject, type: :merge_request) { _('Merge requests') } + - if force_priority + %li.label-link-item.js-priority-badge.inline.prepend-left-10 + .label-badge.label-badge-blue= _('Prioritized label') diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml index 5e9007aaaac..099e3ac8462 100644 --- a/app/views/shared/_milestone_expired.html.haml +++ b/app/views/shared/_milestone_expired.html.haml @@ -1,7 +1,6 @@ - if milestone.expired? and not milestone.closed? - %span.cred (Expired) + .status-box.status-box-expired.append-bottom-5 Expired - if milestone.upcoming? - %span.clgray (Upcoming) -- if milestone.due_date || milestone.start_date - %span - = milestone_date_range(milestone) + .status-box.status-box-mr-merged.append-bottom-5 Upcoming +- if milestone.closed? + .status-box.status-box-closed.append-bottom-5 Closed diff --git a/app/views/shared/_new_merge_request_checkbox.html.haml b/app/views/shared/_new_merge_request_checkbox.html.haml index 165109b6b70..24c0dfe247f 100644 --- a/app/views/shared/_new_merge_request_checkbox.html.haml +++ b/app/views/shared/_new_merge_request_checkbox.html.haml @@ -1,4 +1,4 @@ -.form-check +.form-check.prepend-top-8 - nonce = SecureRandom.hex = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request form-check-input', id: "create_merge_request-#{nonce}" = label_tag "create_merge_request-#{nonce}", class: 'form-check-label' do diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index b8b1f4ca42f..28407b543b9 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -9,13 +9,17 @@ = form_errors(token) - .form-group - = f.label :name, class: 'label-light' - = f.text_field :name, class: "form-control", required: true + .row + .form-group.col-md-6 + = f.label :name, class: 'label-light' + = f.text_field :name, class: "form-control", required: true - .form-group - = f.label :expires_at, class: 'label-light' - = f.text_field :expires_at, class: "datepicker form-control" + .row + .form-group.col-md-6 + = f.label :expires_at, class: 'label-light' + .input-icon-wrapper + = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD' + = icon('calendar', { class: 'input-icon-right' }) .form-group = f.label :scopes, class: 'label-light' diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 0ebf365c7bd..6fa61c15493 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -3,8 +3,9 @@ - if lookup_context.template_exists?('help', "projects/services/#{@service.to_param}", true) = render "projects/services/#{@service.to_param}/help", subject: subject - elsif @service.help.present? - .card - = markdown @service.help + .info-well + .well-segment + = markdown @service.help .service-settings - if @service.show_active_box? @@ -15,25 +16,24 @@ - if @service.configurable_events.present? .form-group.row - = form.label :url, "Trigger", class: 'col-form-label col-sm-2' + .col-sm-2.text-right Trigger .col-sm-10 - @service.configurable_events.each do |event| - %div - = form.check_box service_event_field_name(event), class: 'float-left' - .prepend-left-20 - = form.label service_event_field_name(event), class: 'list-label' do + .form-group + .form-check + = form.check_box service_event_field_name(event), class: 'form-check-input' + = form.label service_event_field_name(event), class: 'form-check-label' do %strong = event.humanize - - field = @service.event_field(event) + - field = @service.event_field(event) - - if field - %p + - if field = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] - %p.light - = @service.class.event_description(event) + %p.text-muted + = @service.class.event_description(event) - @service.global_fields.each do |field| - type = field[:type] diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml index d67409ffe14..01ce1225b8d 100644 --- a/app/views/shared/_visibility_level.html.haml +++ b/app/views/shared/_visibility_level.html.haml @@ -1,11 +1,11 @@ - with_label = local_assigns.fetch(:with_label, true) -.form-group.visibility-level-setting +.form-group.row.visibility-level-setting - if with_label = f.label :visibility_level, class: 'col-form-label col-sm-2' do Visibility Level = link_to icon('question-circle'), help_page_path("public_access/public_access") - %div{ :class => ("col-sm-10" if with_label) } + %div{ :class => (with_label ? "col-sm-10" : "col-sm-12") } - if can_change_visibility_level = render('shared/visibility_radios', model_method: :visibility_level, form: f, selected_level: visibility_level, form_model: form_model) - else diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 496b94ec953..a88d8f61fb4 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -3,8 +3,8 @@ - @no_breadcrumb_container = true - @no_container = true - @content_class = "issue-boards-content" -- breadcrumb_title "Issue Board" -- page_title "Boards" +- breadcrumb_title _("Issue Board") +- page_title _("Boards") - content_for :page_specific_javascripts do diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index 67476a3f573..03e008f5fa0 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -1,4 +1,4 @@ -.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded }', +.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', ":data-id" => "list.id" } .board-inner %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } @@ -7,10 +7,18 @@ ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", "aria-hidden": "true" } + %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" } + -# haml-lint:disable AltText + %img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" } + %span.board-title-text.has-tooltip{ "v-if": "list.type !== \"label\"", - ":title" => '(list.label ? list.label.description : "")', data: { container: "body" } } + ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } } {{ list.title }} + %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"", + ":title" => '(list.assignee && list.assignee.username || "")' } + @{{ list.assignee.username }} + %span.has-tooltip{ "v-if": "list.type === \"label\"", ":title" => '(list.label ? list.label.description : "")', data: { container: "body", placement: "bottom" }, @@ -22,21 +30,20 @@ %board-delete{ "inline-template" => true, ":list" => "list", "v-if" => "!list.preset && list.id" } - %button.board-delete.has-tooltip.float-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } + %button.board-delete.has-tooltip.float-right{ type: "button", title: _("Delete list"), "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } = icon("trash") - .issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank"' } + .issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' } %span.issue-count-badge-count.float-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' } {{ list.issuesSize }} - if can?(current_user, :admin_list, current_board_parent) %button.issue-count-badge-add-button.btn.btn-sm.btn-default.has-tooltip.js-no-trigger-collapse{ type: "button", "@click" => "showNewIssueForm", "v-if" => 'list.type !== "closed"', - "aria-label" => "New issue", - "title" => "New issue", + "aria-label" => _("New issue"), + "title" => _("New issue"), data: { placement: "top", container: "body" } } = icon("plus", class: "js-no-trigger-collapse") - - %board-list{ "v-if" => 'list.type !== "blank"', + %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":list" => "list", ":issues" => "list.issues", ":loading" => "list.loading", @@ -47,3 +54,4 @@ "ref" => "board-list" } - if can?(current_user, :admin_list, current_board_parent) %board-blank-state{ "v-if" => 'list.id == "blank"' } + = render_if_exists 'shared/boards/board_promotion_state' diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index 774dafe5f2c..1ff956649ed 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -8,6 +8,7 @@ {{ issue.title }} %br/ %span + = render_if_exists "shared/boards/components/sidebar/issue_project_path" = precede "#" do {{ issue.iid }} %a.gutter-toggle.float-right{ role: "button", @@ -17,9 +18,11 @@ = custom_icon("icon_close", size: 15) .js-issuable-update = render "shared/boards/components/sidebar/assignee" + = render_if_exists "shared/boards/components/sidebar/epic" = render "shared/boards/components/sidebar/milestone" = render "shared/boards/components/sidebar/due_date" = render "shared/boards/components/sidebar/labels" + = render_if_exists "shared/boards/components/sidebar/weight" = render "shared/boards/components/sidebar/notifications" %remove-btn{ ":issue" => "issue", ":issue-update" => "issue.sidebarInfoEndpoint", diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml index 10217b6cbf0..5630375f428 100644 --- a/app/views/shared/boards/components/sidebar/_due_date.html.haml +++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml @@ -1,20 +1,20 @@ .block.due_date .title - Due date + = _("Due date") - if can_admin_issue? = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link float-right" + = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link float-right" .value .value-content %span.no-value{ "v-if" => "!issue.dueDate" } - No due date + = _("No due date") %span.bold{ "v-if" => "issue.dueDate" } {{ issue.dueDate | due-date }} - if can_admin_issue? %span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" } \- %a.js-remove-due-date{ href: "#", role: "button" } - remove due date + = _('remove due date') - if can_admin_issue? .selectbox %input{ type: "hidden", @@ -23,9 +23,9 @@ .dropdown %button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button', data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" } } - %span.dropdown-toggle-text Due date + %span.dropdown-toggle-text= _("Due date") = icon('chevron-down') .dropdown-menu.dropdown-menu-due-date - = dropdown_title('Due date') + = dropdown_title(_('Due date')) = dropdown_content do .js-due-date-calendar diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index a112a9f1f7e..607e7f471c9 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -1,15 +1,15 @@ .block.labels .title - Labels + = _("Labels") - if can_admin_issue? = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link float-right" + = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link float-right" .value.issuable-show-labels.dont-hide %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } - None + = _("None") %a{ href: "#", "v-for" => "label in issue.labels" } - %span.badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } + .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } {{ label.title }} - if can_admin_issue? .selectbox @@ -28,7 +28,7 @@ namespace_path: @namespace_path, project_path: @project.try(:path) } } %span.dropdown-toggle-text - Label + = _("Label") = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable = render partial: "shared/issuable/label_page_default" diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml index f2bedd5e3c9..b15d60002fc 100644 --- a/app/views/shared/boards/components/sidebar/_milestone.html.haml +++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml @@ -1,12 +1,12 @@ .block.milestone .title - Milestone + = _("Milestone") - if can_admin_issue? = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link float-right" + = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link float-right" .value %span.no-value{ "v-if" => "!issue.milestone" } - None + = _("None") %span.bold.has-tooltip{ "v-if" => "issue.milestone" } {{ issue.milestone.title }} - if can_admin_issue? @@ -19,10 +19,10 @@ %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" }, ":data-selected" => "milestoneTitle", ":data-issuable-id" => "issue.iid" } - Milestone + = _("Milestone") = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-selectable - = dropdown_title("Assign milestone") - = dropdown_filter("Search milestones") + = dropdown_title(_("Assign milestone")) + = dropdown_filter(_("Search milestones")) = dropdown_content = dropdown_loading diff --git a/app/views/shared/builds/_build_output.html.haml b/app/views/shared/builds/_build_output.html.haml new file mode 100644 index 00000000000..0e18128a8f1 --- /dev/null +++ b/app/views/shared/builds/_build_output.html.haml @@ -0,0 +1,3 @@ +%pre.build-trace#build-trace + %code.bash.js-build-output + .build-loader-animation.js-build-refresh diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index c7c33288e9d..2e26fe63d3e 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -16,7 +16,7 @@ - if has_button .text-center - if project_select_button - = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues + = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues, with_feature_enabled: 'issues' - else = link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link' - else diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index 014220761a9..186139f3526 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -15,7 +15,7 @@ = _("Interested parties can even contribute by pushing commits if they want to.") .text-center - if project_select_button - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('New merge request'), type: :merge_requests + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: _('New merge request'), type: :merge_requests, with_feature_enabled: 'merge_requests' - else = link_to _('New merge request'), button_path, class: 'btn btn-new', title: _('New merge request'), id: 'new_merge_request_link' - else diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index fabb1f39a34..f1a41074c28 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -8,7 +8,7 @@ %h4 = s_('WikiEmpty|The wiki lets you write documentation for your project') %p.text-left - = s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, it's principles, how to use it, and so on.") + = s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, its principles, how to use it, and so on.") = create_link - elsif can?(current_user, :read_issue, @project) diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index e5dfa7dbf71..25df2fe5cd6 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -16,7 +16,7 @@ = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea qa-issuable-form-description', - placeholder: "Write a comment or drag your files here...", + placeholder: "Write a comment or drag your files here…", supports_quick_actions: supports_quick_actions = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions .clearfix diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml new file mode 100644 index 00000000000..23b2e1b91e5 --- /dev/null +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -0,0 +1,8 @@ +.dropdown.prepend-left-10#js-add-list + %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } + Add list + .dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels + = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } + - if can?(current_user, :admin_label, board.parent) + = render partial: "shared/issuable/label_page_create" + = dropdown_loading diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index 9f4021802df..55edaa7eda4 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -1,6 +1,7 @@ +- show_close = local_assigns.fetch(:show_close, true) - subject = @project || @group .dropdown-page-two.dropdown-new-label - = dropdown_title(create_label_title(subject), options: { back: true }) + = dropdown_title(create_label_title(subject), options: { back: true, close: show_close }) = dropdown_content do .dropdown-labels-error.js-label-error %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index 2bd922bca2b..aa4a5f0e0d3 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -1,15 +1,18 @@ - title = local_assigns.fetch(:title, _('Assign labels')) +- content_title = local_assigns.fetch(:content_title, _('Create lists from labels. Issues with that label appear in that list.')) +- show_title = local_assigns.fetch(:show_title, true) - show_create = local_assigns.fetch(:show_create, true) - show_footer = local_assigns.fetch(:show_footer, true) - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search') - show_boards_content = local_assigns.fetch(:show_boards_content, false) - subject = @project || @group .dropdown-page-one - = dropdown_title(title) + - if show_title + = dropdown_title(title) - if show_boards_content .issue-board-dropdown-content %p - = _('Create lists from labels. Issues with that label appear in that list.') + = content_title = dropdown_filter(filter_placeholder) = dropdown_content - if current_board_parent && show_footer diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index 955b8866c2c..37625a4a163 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -1,6 +1,8 @@ - project = @target_project || @project - extra_class = extra_class || '' - show_menu_above = show_menu_above || false +- selected = local_assigns.fetch(:selected, nil) + - selected_text = selected.try(:title) || params[:milestone_title] - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by milestone") - if selected.present? || params[:milestone_title].present? diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 644f7c4dd28..ef9ea2194ee 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -104,14 +104,7 @@ .filter-dropdown-container - if type == :boards - if can?(current_user, :admin_list, board.parent) - .dropdown.prepend-left-10#js-add-list - %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } - Add list - .dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable - = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } - - if can?(current_user, :admin_label, board.parent) - = render partial: "shared/issuable/label_page_create" - = dropdown_loading + = render_if_exists 'shared/issuable/board_create_list_dropdown', board: board - if @project #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - elsif type != :boards_modal diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9e50e888b35..0ca35ea1298 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -43,7 +43,7 @@ .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil - = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }}) + = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true, display: 'static' }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block // Fallback while content is loading @@ -77,7 +77,7 @@ .selectbox.hide-collapsed = f.hidden_field :due_date, value: issuable.due_date.try(:strftime, 'yy-mm-dd') .dropdown - %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable) } } + %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), display: 'static' } } %span.dropdown-toggle-text = _('Due date') = icon('chevron-down', 'aria-hidden': 'true') @@ -109,7 +109,7 @@ - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path(false) if @project) } } + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path(false) if @project), display: 'static' } } %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index e1cde527ad7..8a13c7a3b83 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -37,7 +37,7 @@ - issuable.assignees.each do |assignee| = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } - - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: _('Search users'), data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: _('Search users'), data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, display: 'static' } } - title = _('Select assignee') - if issuable.is_a?(Issue) @@ -50,7 +50,7 @@ - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - - data['max-select'] = dropdown_options[:data][:'max-select'] + - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] - options[:data].merge!(data) = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml index b34549240e0..bc9a1edc39c 100644 --- a/app/views/shared/issuable/form/_contribution.html.haml +++ b/app/views/shared/issuable/form/_contribution.html.haml @@ -7,14 +7,14 @@ %hr -.form-group - .col-form-label +.form-group.row + %label.col-form-label.col-sm-2 = _('Contribution') .col-sm-10 - .form-check - = form.check_box :allow_maintainer_to_push, disabled: !issuable.can_allow_maintainer_to_push?(current_user), class: 'form-check-input' - = form.label :allow_maintainer_to_push, class: 'form-check-label' do - = _('Allow edits from maintainers.') - = link_to 'About this feature', help_page_path('user/project/merge_requests/maintainer_access') + .form-check.prepend-top-5 + = form.check_box :allow_collaboration, disabled: !issuable.can_allow_collaboration?(current_user), class: 'form-check-input' + = form.label :allow_collaboration, class: 'form-check-label' do + = _('Allow commits from members who can merge to the target branch.') + = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration') .form-text.text-muted - = allow_maintainer_push_unavailable_reason(issuable) + = allow_collaboration_unavailable_reason(issuable) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 1e27253aaeb..bd87bb38e77 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -15,17 +15,20 @@ - else = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date .form-group.row.issue-milestone - = form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-2"}" - .col-10{ class: ("col-md-8" if has_due_date) } + = form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" + .col-sm-10{ class: ("col-md-8" if has_due_date) } .issuable-form-select-holder = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" .form-group.row - has_labels = @labels && @labels.any? - = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-2"}" + = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" = form.hidden_field :label_ids, multiple: true, value: '' - .col-10{ class: "#{"col-md-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } + .col-sm-10{ class: "#{"col-md-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } .issuable-form-select-holder - = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false}, dropdown_title: "Select label" + = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label" + + = render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form + - if has_due_date .col-lg-6 .form-group.row diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index c35d0b3751f..e49bdec386a 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -6,7 +6,7 @@ %div{ class: div_class } = form.text_field :title, required: true, maxlength: 255, autofocus: true, - autocomplete: 'off', class: 'form-control pad qa-issuable-form-title' + autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title') - if issuable.respond_to?(:work_in_progress?) %p.form-text.text-muted diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 67b8843a27f..d0b492b43f3 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -5,16 +5,16 @@ %li.member.group_member{ id: dom_id } %span.list-item-name = group_icon(group, class: "avatar s40", alt: '') - %strong - = link_to group.full_name, group_path(group) - .cgray - Given access #{time_ago_with_tooltip(group_link.created_at)} - - if group_link.expires? - · - %span{ class: ('text-warning' if group_link.expires_soon?) } - Expires in #{distance_of_time_in_words_to_now(group_link.expires_at)} + .user-info + = link_to group.full_name, group_path(group), class: 'member' + .cgray + Given access #{time_ago_with_tooltip(group_link.created_at)} + - if group_link.expires? + · + %span{ class: ('text-warning' if group_link.expires_soon?) } + Expires in #{distance_of_time_in_words_to_now(group_link.expires_at)} .controls.member-controls - = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form' do + = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group row append-right-5' do = hidden_field_tag "group_link[group_access]", group_link.group_access .member-form-control.dropdown.append-right-5 %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button", diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 608dd35182d..922805958a5 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -2,10 +2,10 @@ .form-group.row = f.label :start_date, "Start Date", class: "col-form-label col-sm-2" .col-sm-10 - = f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date" + = f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date .form-group.row = f.label :due_date, "Due Date", class: "col-form-label col-sm-2" .col-sm-10 - = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date" + = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 09bbd04c2bf..c559945a9c9 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -1,76 +1,59 @@ - dashboard = local_assigns[:dashboard] - custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone) +- milestone_type = milestone.group_milestone? ? 'Group Milestone' : 'Project Milestone' %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } .row .col-sm-6 - %strong= link_to truncate(milestone.title, length: 100), milestone_path - - if milestone.group_milestone? - %span - Group Milestone - - else - %span - Project Milestone + .append-bottom-5 + %strong= link_to truncate(milestone.title, length: 100), milestone_path + - if @group + = " - #{milestone_type}" - .col-sm-6 - .float-right.light #{milestone.percent_complete(current_user)}% complete - .row - .col-sm-6 + - if @project || milestone.is_a?(GlobalMilestone) || milestone.group_milestone? + - if milestone.due_date || milestone.start_date + .milestone-range.append-bottom-5 + = milestone_date_range(milestone) + %div + = render('shared/milestone_expired', milestone: milestone) + - if milestone.legacy_group_milestone? + .projects + - milestone.milestones.each do |milestone| + = link_to milestone_path(milestone) do + %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5 + = dashboard ? milestone.project.full_name : milestone.project.name + + .col-sm-4.milestone-progress + = milestone_progress_bar(milestone) = link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path · = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path - .col-sm-6= milestone_progress_bar(milestone) - - if milestone.is_a?(GlobalMilestone) || milestone.group_milestone? - .row - .col-sm-6 - - if milestone.legacy_group_milestone? - .expiration= render('shared/milestone_expired', milestone: milestone) - .projects - - milestone.milestones.each do |milestone| - = link_to milestone_path(milestone) do - %span.badge.badge-gray - = dashboard ? milestone.project.full_name : milestone.project.name - - if @group - .col-sm-6.milestone-actions + .float-lg-right.light #{milestone.percent_complete(current_user)}% complete + .col-sm-2 + .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end + - if @project + - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? + - if @project.group + %button.js-promote-project-milestone-button.btn.btn-blank.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), + disabled: true, + type: 'button', + data: { url: promote_project_milestone_path(milestone.project, milestone), + milestone_title: milestone.title, + group_name: @project.group.name, + target: '#promote-milestone-modal', + container: 'body', + toggle: 'modal' } } + = sprite_icon('level-up', size: 14) + + = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped" + - unless milestone.active? + = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" + - if @group - if can?(current_user, :admin_milestones, @group) - - if milestone.group_milestone? - = link_to edit_group_milestone_path(@group, milestone), class: "btn btn-sm btn-grouped" do - Edit - \ - if milestone.closed? = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" - else = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close" - - - if @project - .row - .col-sm-6 - = render('shared/milestone_expired', milestone: milestone) - .col-sm-6.milestone-actions - - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? - = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-sm btn-grouped" do - Edit - \ - - - if @project.group - %button.js-promote-project-milestone-button.btn.btn-sm.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), - disabled: true, - type: 'button', - data: { url: promote_project_milestone_path(milestone.project, milestone), - milestone_title: milestone.title, - group_name: @project.group.name, - target: '#promote-milestone-modal', - container: 'body', - toggle: 'modal' } } - = _('Promote') - - = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped" - - %button.js-delete-milestone-button.btn.btn-sm.btn-grouped.btn-danger{ data: { toggle: 'modal', - target: '#delete-milestone-modal', - milestone_id: milestone.id, - milestone_title: markdown_field(milestone, :title), - milestone_url: project_milestone_path(milestone.project, milestone), - milestone_issue_count: milestone.issues.count, - milestone_merge_request_count: milestone.merge_requests.count }, - disabled: true } - = _('Delete') - = icon('spin spinner', class: 'js-loading-icon hidden' ) + - if dashboard + .status-box.status-box-milestone + = milestone_type diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index 8923e5602a4..71a5b94e958 100644 --- a/app/views/shared/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -3,7 +3,7 @@ = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do - = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..." + = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here…" = render 'shared/notes/hints' .note-form-actions.clearfix diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 71c0d740bc8..6b2715b47a7 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -29,7 +29,7 @@ = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', - placeholder: "Write a comment or drag your files here...", + placeholder: "Write a comment or drag your files here…", supports_quick_actions: supports_quick_actions, supports_autocomplete: supports_autocomplete = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions @@ -40,5 +40,5 @@ = yield(:note_actions) - %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } + %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Discard draft" } } Discard draft diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index ca6e3602f05..d4e8f30e458 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -36,8 +36,6 @@ = note.author.to_reference %span.note-headline-light %span.note-headline-meta - - unless note.system - commented - if note.system %span.system-note-message = markdown_field(note, :note) @@ -61,7 +59,7 @@ .note-awards = render 'award_emoji/awards_block', awardable: note, inline: false - if note.system - .system-note-commit-list-toggler + .system-note-commit-list-toggler.hide Toggle commit list %i.fa.fa-angle-down - if note.attachment.url diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index b98d5339d2d..e0832fd9136 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,14 +1,13 @@ - issuable = @issue || @merge_request - discussion_locked = issuable&.discussion_locked? -- unless has_vue_discussions_cookie? - %ul#notes-list.notes.main-notes-list.timeline - = render "shared/notes/notes" +%ul#notes-list.notes.main-notes-list.timeline + = render "shared/notes/notes" = render 'shared/notes/edit_form', project: @project - if can_create_note? - %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) } + %ul.notes.notes-form.timeline %li.timeline-entry .timeline-entry-inner .flash-container.timeline-content diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index e99d8d0973f..09ddf732ada 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -6,14 +6,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting) } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting) } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) = icon("caret-down") diff --git a/app/views/shared/projects/_edit_information.html.haml b/app/views/shared/projects/_edit_information.html.haml index ec9dc8f62c2..9230e045a81 100644 --- a/app/views/shared/projects/_edit_information.html.haml +++ b/app/views/shared/projects/_edit_information.html.haml @@ -1,6 +1,6 @@ - unless can?(current_user, :push_code, @project) .inline.prepend-left-10 - - if @project.branch_allows_maintainer_push?(current_user, selected_branch) + - if @project.branch_allows_collaboration?(current_user, selected_branch) = commit_in_single_accessible_branch - else = commit_in_fork_help diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/show.html.haml index e50b7fa68dd..96527fcb4f2 100644 --- a/app/views/shared/runners/show.html.haml +++ b/app/views/shared/runners/show.html.haml @@ -21,17 +21,17 @@ %th Value %tr %td Active - %td= @runner.active? ? _('Yes') : _('No') + %td= @runner.active? ? 'Yes' : 'No' %tr %td Protected - %td= @runner.ref_protected? ? _('Yes') : _('No') + %td= @runner.active? ? _('Yes') : _('No') %tr - %td= _('Can run untagged jobs') - %td= @runner.run_untagged? ? _('Yes') : _('No') + %td Can run untagged jobs + %td= @runner.run_untagged? ? 'Yes' : 'No' - unless @runner.group_type? %tr - %td= _('Locked to this project') - %td= @runner.locked? ? _('Yes') : _('No') + %td Locked to this project + %td= @runner.locked? ? 'Yes' : 'No' %tr %td Tags %td @@ -60,7 +60,7 @@ %td Description %td= @runner.description %tr - %td= _('Maximum job timeout') + %td Maximum job timeout %td= @runner.maximum_timeout_human_readable %tr %td Last contact diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index ae437dd16d6..dcb3fca23f2 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -3,8 +3,7 @@ - token = local_assigns.fetch(:token) - scopes.each do |scope| - %fieldset - = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}" - = label_tag ("#{prefix}_scopes_#{scope}"), scope - %span= t(scope, scope: [:doorkeeper, :scopes]) - .scope-description= t scope, scope: [:doorkeeper, :scope_desc] + %fieldset.form-group.form-check + = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: 'form-check-input' + = label_tag ("#{prefix}_scopes_#{scope}"), scope, class: 'label-light form-check-label' + .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml index 7eb221620ad..1c788b9a737 100644 --- a/app/views/u2f/_authenticate.html.haml +++ b/app/views/u2f/_authenticate.html.haml @@ -2,9 +2,6 @@ %a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' } Sign in via 2FA code -# haml-lint:disable InlineJavaScript -%script#js-authenticate-u2f-not-supported{ type: "text/template" } - %p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer). - %script#js-authenticate-u2f-in-progress{ type: "text/template" } %p Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now. diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml index e0fe551cf36..a869cf9cdee 100644 --- a/app/views/users/terms/index.html.haml +++ b/app/views/users/terms/index.html.haml @@ -1,13 +1,18 @@ - redirect_params = { redirect: @redirect } if @redirect -.panel-content.rendered-terms +.card-body.rendered-terms = markdown_field(@term, :terms) -.row-content-block.footer-block.clearfix - - if can?(current_user, :accept_terms, @term) - .float-right - = button_to accept_term_path(@term, redirect_params), class: 'btn btn-success prepend-left-8' do - = _('Accept terms') - - if can?(current_user, :decline_terms, @term) - .float-right - = button_to decline_term_path(@term, redirect_params), class: 'btn btn-default prepend-left-8' do - = _('Decline and sign out') +- if current_user + .card-footer.footer-block.clearfix + - if can?(current_user, :accept_terms, @term) + .float-right + = button_to accept_term_path(@term, redirect_params), class: 'btn btn-success prepend-left-8' do + = _('Accept terms') + - else + .pull-right + = link_to root_path, class: 'btn btn-success prepend-left-8' do + = _('Continue') + - if can?(current_user, :decline_terms, @term) + .float-right + = button_to decline_term_path(@term, redirect_params), class: 'btn btn-default prepend-left-8' do + = _('Decline and sign out') diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb index 044e470141e..06324575ffc 100644 --- a/app/workers/admin_email_worker.rb +++ b/app/workers/admin_email_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AdminEmailWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 93e57512edb..b8b854853b7 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -11,14 +11,16 @@ - cronjob:remove_old_web_hook_logs - cronjob:remove_unreferenced_lfs_objects - cronjob:repository_archive_cache -- cronjob:repository_check_batch +- cronjob:repository_check_dispatch - cronjob:requests_profiles - cronjob:schedule_update_user_activity - cronjob:stuck_ci_jobs - cronjob:stuck_import_jobs - cronjob:stuck_merge_jobs +- cronjob:ci_archive_traces_cron - cronjob:trending_projects - cronjob:issue_due_scheduler +- cronjob:prune_web_hook_logs - gcp_cluster:cluster_install_app - gcp_cluster:cluster_provision @@ -30,12 +32,14 @@ - github_importer:github_import_import_diff_note - github_importer:github_import_import_issue - github_importer:github_import_import_note +- github_importer:github_import_import_lfs_object - github_importer:github_import_import_pull_request - github_importer:github_import_refresh_import_jid - github_importer:github_import_stage_finish_import - github_importer:github_import_stage_import_base_data - github_importer:github_import_stage_import_issues_and_diff_notes - github_importer:github_import_stage_import_notes +- github_importer:github_import_stage_import_lfs_objects - github_importer:github_import_stage_import_pull_requests - github_importer:github_import_stage_import_repository @@ -68,6 +72,7 @@ - pipeline_processing:update_head_pipeline_for_merge_request - repository_check:repository_check_clear +- repository_check:repository_check_batch - repository_check:repository_check_single_repository - default @@ -115,3 +120,4 @@ - web_hook - repository_update_remote_mirror - create_note_diff_file +- delete_diff_files diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb index dea7425ad88..9169f21af2a 100644 --- a/app/workers/archive_trace_worker.rb +++ b/app/workers/archive_trace_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ArchiveTraceWorker include ApplicationWorker include PipelineBackgroundQueue diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 8fe3619f6ee..dd62bb0f33d 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AuthorizedProjectsWorker include ApplicationWorker prepend WaitableWorker diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb index 376703f6319..eaec7d48f35 100644 --- a/app/workers/background_migration_worker.rb +++ b/app/workers/background_migration_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BackgroundMigrationWorker include ApplicationWorker diff --git a/app/workers/build_coverage_worker.rb b/app/workers/build_coverage_worker.rb index 62b212c79be..53d77dc4524 100644 --- a/app/workers/build_coverage_worker.rb +++ b/app/workers/build_coverage_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BuildCoverageWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index 46f1ac09915..9dc2c7f3601 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BuildFinishedWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index cbfca8c342c..f1f71dc589c 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BuildHooksWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index e4f4e6c1d9e..1b3f1fd3c2a 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BuildQueueWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index 4b9097bc5e4..e1c1cc24a94 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BuildSuccessWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/build_trace_sections_worker.rb b/app/workers/build_trace_sections_worker.rb index c0f5c144e10..f4114b3353c 100644 --- a/app/workers/build_trace_sections_worker.rb +++ b/app/workers/build_trace_sections_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class BuildTraceSectionsWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb new file mode 100644 index 00000000000..7016edde698 --- /dev/null +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ci + class ArchiveTracesCronWorker + include ApplicationWorker + include CronjobQueue + + def perform + # Archive stale live traces which still resides in redis or database + # This could happen when ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL + # More details in https://gitlab.com/gitlab-org/gitlab-ce/issues/36791 + Ci::Build.finished.with_live_trace.find_each(batch_size: 100) do |build| + begin + build.trace.archive! + rescue => e + failed_archive_counter.increment + Rails.logger.error "Failed to archive stale live trace. id: #{build.id} message: #{e.message}" + end + end + end + + private + + def failed_archive_counter + @failed_archive_counter ||= Gitlab::Metrics.counter(:job_trace_archive_failed_total, "Counter of failed attempts of traces archiving") + end + end +end diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb index 218d6688bd9..6376c6d32cf 100644 --- a/app/workers/ci/build_trace_chunk_flush_worker.rb +++ b/app/workers/ci/build_trace_chunk_flush_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Ci class BuildTraceChunkFlushWorker include ApplicationWorker diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb index f771cb4939f..32e2ea7996c 100644 --- a/app/workers/cluster_install_app_worker.rb +++ b/app/workers/cluster_install_app_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ClusterInstallAppWorker include ApplicationWorker include ClusterQueue diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index 1ab4de3b647..59de7903c1c 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ClusterProvisionWorker include ApplicationWorker include ClusterQueue diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb index d564d5e48bf..e8d7e52f70f 100644 --- a/app/workers/cluster_wait_for_app_installation_worker.rb +++ b/app/workers/cluster_wait_for_app_installation_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ClusterWaitForAppInstallationWorker include ApplicationWorker include ClusterQueue diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb index 8ba5951750c..6865384df44 100644 --- a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb +++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ClusterWaitForIngressIpAddressWorker include ApplicationWorker include ClusterQueue diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 37586e161c9..bb06e31641d 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Sidekiq::Worker.extend ActiveSupport::Concern module ApplicationWorker diff --git a/app/workers/concerns/cluster_applications.rb b/app/workers/concerns/cluster_applications.rb index 24ecaa0b52f..9758a1ceb0e 100644 --- a/app/workers/concerns/cluster_applications.rb +++ b/app/workers/concerns/cluster_applications.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ClusterApplications extend ActiveSupport::Concern diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb index 24b9f145220..e44b40c36c9 100644 --- a/app/workers/concerns/cluster_queue.rb +++ b/app/workers/concerns/cluster_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ## # Concern for setting Sidekiq settings for the various Gcp clusters workers. # diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb index b6581779f6a..0683b229381 100644 --- a/app/workers/concerns/cronjob_queue.rb +++ b/app/workers/concerns/cronjob_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Concern that sets various Sidekiq settings for workers executed using a # cronjob. module CronjobQueue diff --git a/app/workers/concerns/each_shard_worker.rb b/app/workers/concerns/each_shard_worker.rb new file mode 100644 index 00000000000..d0a728fb495 --- /dev/null +++ b/app/workers/concerns/each_shard_worker.rb @@ -0,0 +1,31 @@ +module EachShardWorker + extend ActiveSupport::Concern + include ::Gitlab::Utils::StrongMemoize + + def each_eligible_shard + Gitlab::ShardHealthCache.update(eligible_shard_names) + + eligible_shard_names.each do |shard_name| + yield shard_name + end + end + + # override when you want to filter out some shards + def eligible_shard_names + healthy_shard_names + end + + def healthy_shard_names + strong_memoize(:healthy_shard_names) do + healthy_ready_shards.map { |result| result.labels[:shard] } + end + end + + def healthy_ready_shards + ready_shards.select(&:success) + end + + def ready_shards + Gitlab::HealthChecks::GitalyCheck.readiness + end +end diff --git a/app/workers/concerns/exception_backtrace.rb b/app/workers/concerns/exception_backtrace.rb index ea0f1f8d19b..37c9eaba0d7 100644 --- a/app/workers/concerns/exception_backtrace.rb +++ b/app/workers/concerns/exception_backtrace.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Concern for enabling a few lines of exception backtraces in Sidekiq module ExceptionBacktrace extend ActiveSupport::Concern diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb index 22c2ce458e8..59b621f16ab 100644 --- a/app/workers/concerns/gitlab/github_import/queue.rb +++ b/app/workers/concerns/gitlab/github_import/queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Gitlab module GithubImport module Queue diff --git a/app/workers/concerns/mail_scheduler_queue.rb b/app/workers/concerns/mail_scheduler_queue.rb index f3e9680d756..c051151e973 100644 --- a/app/workers/concerns/mail_scheduler_queue.rb +++ b/app/workers/concerns/mail_scheduler_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MailSchedulerQueue extend ActiveSupport::Concern diff --git a/app/workers/concerns/new_issuable.rb b/app/workers/concerns/new_issuable.rb index 526ed0bad07..7735dec5e6b 100644 --- a/app/workers/concerns/new_issuable.rb +++ b/app/workers/concerns/new_issuable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module NewIssuable attr_reader :issuable, :user diff --git a/app/workers/concerns/object_storage_queue.rb b/app/workers/concerns/object_storage_queue.rb index a80f473a6d4..8650eed213a 100644 --- a/app/workers/concerns/object_storage_queue.rb +++ b/app/workers/concerns/object_storage_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers. module ObjectStorageQueue extend ActiveSupport::Concern diff --git a/app/workers/concerns/pipeline_background_queue.rb b/app/workers/concerns/pipeline_background_queue.rb index 8bf43de6b26..bbb8ad0c982 100644 --- a/app/workers/concerns/pipeline_background_queue.rb +++ b/app/workers/concerns/pipeline_background_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ## # Concern for setting Sidekiq settings for the low priority CI pipeline workers. # diff --git a/app/workers/concerns/pipeline_queue.rb b/app/workers/concerns/pipeline_queue.rb index e77093a6902..3aaed4669e5 100644 --- a/app/workers/concerns/pipeline_queue.rb +++ b/app/workers/concerns/pipeline_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ## # Concern for setting Sidekiq settings for the various CI pipeline workers. # diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb index ef23990ad97..22bdf441d6b 100644 --- a/app/workers/concerns/project_import_options.rb +++ b/app/workers/concerns/project_import_options.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ProjectImportOptions extend ActiveSupport::Concern diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb index 4e55a1ee3d6..46a133db2a1 100644 --- a/app/workers/concerns/project_start_import.rb +++ b/app/workers/concerns/project_start_import.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Used in EE by mirroring module ProjectStartImport def start(project) diff --git a/app/workers/concerns/repository_check_queue.rb b/app/workers/concerns/repository_check_queue.rb index 43fb66c31b0..216d67e5dbc 100644 --- a/app/workers/concerns/repository_check_queue.rb +++ b/app/workers/concerns/repository_check_queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Concern for setting Sidekiq settings for the various repository check workers. module RepositoryCheckQueue extend ActiveSupport::Concern diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb index 48ebe862248..d85bc7d1660 100644 --- a/app/workers/concerns/waitable_worker.rb +++ b/app/workers/concerns/waitable_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WaitableWorker extend ActiveSupport::Concern diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb index f371731f68c..a2da1bda11f 100644 --- a/app/workers/create_gpg_signature_worker.rb +++ b/app/workers/create_gpg_signature_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateGpgSignatureWorker include ApplicationWorker diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb index 624b638a24e..0850250f7e3 100644 --- a/app/workers/create_note_diff_file_worker.rb +++ b/app/workers/create_note_diff_file_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateNoteDiffFileWorker include ApplicationWorker diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb index c3ac35e54f5..037b4a57d4b 100644 --- a/app/workers/create_pipeline_worker.rb +++ b/app/workers/create_pipeline_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreatePipelineWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/delete_diff_files_worker.rb b/app/workers/delete_diff_files_worker.rb new file mode 100644 index 00000000000..bb8fbb9c373 --- /dev/null +++ b/app/workers/delete_diff_files_worker.rb @@ -0,0 +1,17 @@ +class DeleteDiffFilesWorker + include ApplicationWorker + + def perform(merge_request_diff_id) + merge_request_diff = MergeRequestDiff.find(merge_request_diff_id) + + return if merge_request_diff.without_files? + + MergeRequestDiff.transaction do + merge_request_diff.clean! + + MergeRequestDiffFile + .where(merge_request_diff_id: merge_request_diff.id) + .delete_all + end + end +end diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb index 07cd1f02fb5..017d7fd1cb0 100644 --- a/app/workers/delete_merged_branches_worker.rb +++ b/app/workers/delete_merged_branches_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DeleteMergedBranchesWorker include ApplicationWorker diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb index 6c431b02979..4d0295f8d2e 100644 --- a/app/workers/delete_user_worker.rb +++ b/app/workers/delete_user_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class DeleteUserWorker include ApplicationWorker diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index dd8a6cbbef1..f9f0efb302a 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmailReceiverWorker include ApplicationWorker diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 2a4d65b5cb3..8d0cfc73ccd 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmailsOnPushWorker include ApplicationWorker diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 87e5dca01fd..5d3a9a39b93 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExpireBuildArtifactsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb index 234b4357cf7..3b57ecb36e3 100644 --- a/app/workers/expire_build_instance_artifacts_worker.rb +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExpireBuildInstanceArtifactsWorker include ApplicationWorker diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index 7217364a9f2..14a57b90114 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExpireJobCacheWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index db73d37868a..992fc63c451 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ExpirePipelineCacheWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index be4203bc7ad..fd49bc18161 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GitGarbageCollectWorker include ApplicationWorker @@ -6,12 +8,6 @@ class GitGarbageCollectWorker # Timeout set to 24h LEASE_TIMEOUT = 86400 - GITALY_MIGRATED_TASKS = { - gc: :garbage_collect, - full_repack: :repack_full, - incremental_repack: :repack_incremental - }.freeze - def perform(project_id, task = :gc, lease_key = nil, lease_uuid = nil) project = Project.find(project_id) active_uuid = get_lease_uuid(lease_key) @@ -27,21 +23,7 @@ class GitGarbageCollectWorker end task = task.to_sym - cmd = command(task) - - gitaly_migrate(GITALY_MIGRATED_TASKS[task]) do |is_enabled| - if is_enabled - gitaly_call(task, project.repository.raw_repository) - else - repo_path = project.repository.path_to_repo - description = "'#{cmd.join(' ')}' in #{repo_path}" - Gitlab::GitLogger.info(description) - - output, status = Gitlab::Popen.popen(cmd, repo_path) - - Gitlab::GitLogger.error("#{description} failed:\n#{output}") unless status.zero? - end - end + gitaly_call(task, project.repository.raw_repository) # Refresh the branch cache in case garbage collection caused a ref lookup to fail flush_ref_caches(project) if task == :gc @@ -82,21 +64,12 @@ class GitGarbageCollectWorker when :incremental_repack client.repack_incremental end - end - - def command(task) - case task - when :gc - git(write_bitmaps: bitmaps_enabled?) + %w[gc] - when :full_repack - git(write_bitmaps: bitmaps_enabled?) + %w[repack -A -d --pack-kept-objects] - when :incremental_repack - # Normal git repack fails when bitmaps are enabled. It is impossible to - # create a bitmap here anyway. - git(write_bitmaps: false) + %w[repack -d] - else - raise "Invalid gc task: #{task.inspect}" - end + rescue GRPC::NotFound => e + Gitlab::GitLogger.error("#{method} failed:\nRepository not found") + raise Gitlab::Git::Repository::NoRepository.new(e) + rescue GRPC::BadStatus => e + Gitlab::GitLogger.error("#{method} failed:\n#{e}") + raise Gitlab::Git::CommandError.new(e) end def flush_ref_caches(project) @@ -108,19 +81,4 @@ class GitGarbageCollectWorker def bitmaps_enabled? Gitlab::CurrentSettings.housekeeping_bitmaps_enabled end - - def git(write_bitmaps:) - config_value = write_bitmaps ? 'true' : 'false' - %W[git -c repack.writeBitmaps=#{config_value}] - end - - def gitaly_migrate(method, &block) - Gitlab::GitalyClient.migrate(method, &block) - rescue GRPC::NotFound => e - Gitlab::GitLogger.error("#{method} failed:\nRepository not found") - raise Gitlab::Git::Repository::NoRepository.new(e) - rescue GRPC::BadStatus => e - Gitlab::GitLogger.error("#{method} failed:\n#{e}") - raise Gitlab::Git::CommandError.new(e) - end end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index 8d708e15a66..be0b6c180b0 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -21,6 +21,7 @@ module Gitlab STAGES = { issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker, notes: Stage::ImportNotesWorker, + lfs_objects: Stage::ImportLfsObjectsWorker, finish: Stage::FinishImportWorker }.freeze diff --git a/app/workers/gitlab/github_import/import_lfs_object_worker.rb b/app/workers/gitlab/github_import/import_lfs_object_worker.rb new file mode 100644 index 00000000000..520c5cb091a --- /dev/null +++ b/app/workers/gitlab/github_import/import_lfs_object_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + class ImportLfsObjectWorker + include ObjectImporter + + def representation_class + Representation::LfsObject + end + + def importer_class + Importer::LfsObjectImporter + end + + def counter_name + :github_importer_imported_lfs_objects + end + + def counter_description + 'The number of imported GitHub Lfs Objects' + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb new file mode 100644 index 00000000000..29257603a9d --- /dev/null +++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Gitlab + module GithubImport + module Stage + class ImportLfsObjectsWorker + include ApplicationWorker + include GithubImport::Queue + include StageMethods + + def perform(project_id) + return unless (project = find_project(project_id)) + + import(project) + end + + # project - An instance of Project. + def import(project) + waiter = Importer::LfsObjectsImporter + .new(project, nil) + .execute + + AdvanceStageWorker.perform_async( + project.id, + { waiter.key => waiter.jobs_remaining }, + :finish + ) + end + end + end + end +end diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index 5f4678a595f..ccf0013180d 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -18,7 +18,7 @@ module Gitlab AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :finish + :lfs_objects ) end end diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb index a0028e41332..0e4d40acc5c 100644 --- a/app/workers/gitlab_shell_worker.rb +++ b/app/workers/gitlab_shell_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GitlabShellWorker include ApplicationWorker include Gitlab::ShellAdapter diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb index 6dd281b1147..b75e724ca98 100644 --- a/app/workers/gitlab_usage_ping_worker.rb +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GitlabUsagePingWorker LEASE_TIMEOUT = 86400 diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index 509bd09dc2e..b4a3ddcae51 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GroupDestroyWorker include ApplicationWorker include ExceptionBacktrace diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb index 9788c8df3a3..da3debdeede 100644 --- a/app/workers/import_export_project_cleanup_worker.rb +++ b/app/workers/import_export_project_cleanup_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ImportExportProjectCleanupWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb index 6774ab307c6..4724ab7ad98 100644 --- a/app/workers/invalid_gpg_signature_update_worker.rb +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class InvalidGpgSignatureUpdateWorker include ApplicationWorker diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index 9ae5456be4c..29631c6b7ac 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'json' require 'socket' @@ -69,8 +71,8 @@ class IrkerWorker newbranch = "#{Gitlab.config.gitlab.url}/#{repo_path}/branches" newbranch = "\x0302\x1f#{newbranch}\x0f" if @colors - privmsg = "[#{repo_name}] #{committer} has created a new branch " - privmsg += "#{branch}: #{newbranch}" + privmsg = "[#{repo_name}] #{committer} has created a new branch " \ + "#{branch}: #{newbranch}" sendtoirker privmsg end @@ -112,9 +114,7 @@ class IrkerWorker url = compare_url data, project.full_path commits = colorize_commits data['total_commits_count'] - new_commits = 'new commit' - new_commits += 's' if data['total_commits_count'] > 1 - + new_commits = 'new commit'.pluralize(data['total_commits_count']) sendtoirker "[#{repo}] #{committer} pushed #{commits} #{new_commits} " \ "to #{branch}: #{url}" end @@ -122,8 +122,8 @@ class IrkerWorker def compare_url(data, repo_path) sha1 = Commit.truncate_sha(data['before']) sha2 = Commit.truncate_sha(data['after']) - compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/compare" - compare_url += "/#{sha1}...#{sha2}" + compare_url = "#{Gitlab.config.gitlab.url}/#{repo_path}/compare" \ + "/#{sha1}...#{sha2}" colorize_url compare_url end @@ -144,8 +144,7 @@ class IrkerWorker def files_count(commit) diff_size = commit.raw_deltas.size - files = "#{diff_size} file" - files += 's' if diff_size > 1 + files = "#{diff_size} file".pluralize(diff_size) files end diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb index 16ab5d069e0..c04a2d75e0b 100644 --- a/app/workers/issue_due_scheduler_worker.rb +++ b/app/workers/issue_due_scheduler_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class IssueDueSchedulerWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/mail_scheduler/issue_due_worker.rb b/app/workers/mail_scheduler/issue_due_worker.rb index 54285884a52..8794ad7a82c 100644 --- a/app/workers/mail_scheduler/issue_due_worker.rb +++ b/app/workers/mail_scheduler/issue_due_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module MailScheduler class IssueDueWorker include ApplicationWorker diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 7cfe0aa0df1..4726e416182 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_job/arguments' module MailScheduler diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index ba832fe30c6..ee864b733cd 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class MergeWorker include ApplicationWorker diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb index adb25c2a170..d9df42c9e17 100644 --- a/app/workers/namespaceless_project_destroy_worker.rb +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Worker to destroy projects that do not have a namespace # # It destroys everything it can without having the info about the namespace it diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 3bc030f9c62..85b53973f56 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NewIssueWorker include ApplicationWorker include NewIssuable diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index bda2a0ab59d..5d8b8904502 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NewMergeRequestWorker include ApplicationWorker include NewIssuable diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 67c54fbf10e..74f34dcf9aa 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NewNoteWorker include ApplicationWorker diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb index 9c4d72e0ecf..8dff65e46e3 100644 --- a/app/workers/object_storage/background_move_worker.rb +++ b/app/workers/object_storage/background_move_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ObjectStorage class BackgroundMoveWorker include ApplicationWorker diff --git a/app/workers/object_storage_upload_worker.rb b/app/workers/object_storage_upload_worker.rb index 5c80f34069c..f17980a83d8 100644 --- a/app/workers/object_storage_upload_worker.rb +++ b/app/workers/object_storage_upload_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # @Deprecated - remove once the `object_storage_upload` queue is empty # The queue has been renamed `object_storage:object_storage_background_upload` # diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb index a3ff4bd2101..92d62a15aee 100644 --- a/app/workers/pages_domain_verification_cron_worker.rb +++ b/app/workers/pages_domain_verification_cron_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PagesDomainVerificationCronWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb index 2e93489113c..4610b688189 100644 --- a/app/workers/pages_domain_verification_worker.rb +++ b/app/workers/pages_domain_verification_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PagesDomainVerificationWorker include ApplicationWorker diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index 66a0ff83bef..13a6576a301 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PagesWorker include ApplicationWorker diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index c94918ff4ee..58023e0af1b 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PipelineHooksWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index d46d1f122fc..a97019b100a 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PipelineMetricsWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/pipeline_notification_worker.rb b/app/workers/pipeline_notification_worker.rb index a9a1168a6e3..3a8846b3747 100644 --- a/app/workers/pipeline_notification_worker.rb +++ b/app/workers/pipeline_notification_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PipelineNotificationWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 24424b3f472..83744c5338a 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PipelineProcessWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index c49758878a4..a1815757735 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PipelineScheduleWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb index 2ab0739a17f..68e9af6a619 100644 --- a/app/workers/pipeline_success_worker.rb +++ b/app/workers/pipeline_success_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PipelineSuccessWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index fc9da2d45b1..c33468c1f14 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PipelineUpdateWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/plugin_worker.rb b/app/workers/plugin_worker.rb index bfcc683d99a..c293e28be4a 100644 --- a/app/workers/plugin_worker.rb +++ b/app/workers/plugin_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PluginWorker include ApplicationWorker diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index f88b3fdbfb1..09a594cdb4e 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PostReceive include ApplicationWorker diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 201e7f332b4..ed39b4a1ea8 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Worker for processing individiual commit messages pushed to a repository. # # Jobs for this worker are scheduled for every commit that is being pushed. As a diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index a993b4b2680..abe86066fb4 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + # Worker for updating any project specific caches. class ProjectCacheWorker include ApplicationWorker + include ExclusiveLeaseGuard LEASE_TIMEOUT = 15.minutes.to_i @@ -11,30 +14,30 @@ class ProjectCacheWorker # statistics - An Array containing columns from ProjectStatistics to # refresh, if empty all columns will be refreshed def perform(project_id, files = [], statistics = []) - project = Project.find_by(id: project_id) - - return unless project && project.repository.exists? + @project = Project.find_by(id: project_id) + return unless @project&.repository&.exists? - update_statistics(project, statistics.map(&:to_sym)) + update_statistics(statistics) - project.repository.refresh_method_caches(files.map(&:to_sym)) + @project.repository.refresh_method_caches(files.map(&:to_sym)) - project.cleanup + @project.cleanup end - def update_statistics(project, statistics = []) - return unless try_obtain_lease_for(project.id, :update_statistics) - - Rails.logger.info("Updating statistics for project #{project.id}") + private - project.statistics.refresh!(only: statistics) + def update_statistics(statistics = []) + try_obtain_lease do + Rails.logger.info("Updating statistics for project #{@project.id}") + @project.statistics.refresh!(only: statistics.to_a.map(&:to_sym)) + end end - private + def lease_timeout + LEASE_TIMEOUT + end - def try_obtain_lease_for(project_id, section) - Gitlab::ExclusiveLease - .new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT) - .try_obtain + def lease_key + "project_cache_worker:#{@project.id}:update_statistics" end end diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb index 1ba854ca4cb..4447e867240 100644 --- a/app/workers/project_destroy_worker.rb +++ b/app/workers/project_destroy_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProjectDestroyWorker include ApplicationWorker include ExceptionBacktrace diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index c3d84bb0b93..ed9da39c7c3 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProjectExportWorker include ApplicationWorker include ExceptionBacktrace diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb index d01eb744e5d..9e4d66250a4 100644 --- a/app/workers/project_migrate_hashed_storage_worker.rb +++ b/app/workers/project_migrate_hashed_storage_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProjectMigrateHashedStorageWorker include ApplicationWorker diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb index 75c4b8b3663..a0bc9288cf0 100644 --- a/app/workers/project_service_worker.rb +++ b/app/workers/project_service_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProjectServiceWorker include ApplicationWorker diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb index 635a97c99af..c9da1cae255 100644 --- a/app/workers/propagate_service_template_worker.rb +++ b/app/workers/propagate_service_template_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Worker for updating any project specific caches. class PropagateServiceTemplateWorker include ApplicationWorker diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb index 5ff62ab1369..c1d05ebbcfd 100644 --- a/app/workers/prune_old_events_worker.rb +++ b/app/workers/prune_old_events_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PruneOldEventsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/prune_web_hook_logs_worker.rb b/app/workers/prune_web_hook_logs_worker.rb new file mode 100644 index 00000000000..45c7d32f7eb --- /dev/null +++ b/app/workers/prune_web_hook_logs_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Worker that deletes a fixed number of outdated rows from the "web_hook_logs" +# table. +class PruneWebHookLogsWorker + include ApplicationWorker + include CronjobQueue + + # The maximum number of rows to remove in a single job. + DELETE_LIMIT = 50_000 + + def perform + # MySQL doesn't allow "DELETE FROM ... WHERE id IN ( ... )" if the inner + # query refers to the same table. To work around this we wrap the IN body in + # another sub query. + WebHookLog + .where( + 'id IN (SELECT id FROM (?) ids_to_remove)', + WebHookLog + .select(:id) + .where('created_at < ?', 90.days.ago.beginning_of_day) + .limit(DELETE_LIMIT) + ) + .delete_all + end +end diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index ef3ddb9024b..9b331f15dc5 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ReactiveCachingWorker include ApplicationWorker diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb index 090987778a2..a6baebc1443 100644 --- a/app/workers/rebase_worker.rb +++ b/app/workers/rebase_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RebaseWorker include ApplicationWorker diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index 7e64c3070a8..6b8b972a440 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RemoveExpiredGroupLinksWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index 68960f72bf6..41913900571 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RemoveExpiredMembersWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb index 87fed42d7ce..17140ac4450 100644 --- a/app/workers/remove_old_web_hook_logs_worker.rb +++ b/app/workers/remove_old_web_hook_logs_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RemoveOldWebHookLogsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb index 8daf079fc31..95e7a9f537f 100644 --- a/app/workers/remove_unreferenced_lfs_objects_worker.rb +++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RemoveUnreferencedLfsObjectsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/repository_archive_cache_worker.rb b/app/workers/repository_archive_cache_worker.rb index 86a258cf94f..c1dff8ced90 100644 --- a/app/workers/repository_archive_cache_worker.rb +++ b/app/workers/repository_archive_cache_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RepositoryArchiveCacheWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb index 72f0a9b0619..051382a08a9 100644 --- a/app/workers/repository_check/batch_worker.rb +++ b/app/workers/repository_check/batch_worker.rb @@ -1,13 +1,20 @@ +# frozen_string_literal: true + module RepositoryCheck class BatchWorker include ApplicationWorker - include CronjobQueue + include RepositoryCheckQueue RUN_TIME = 3600 BATCH_SIZE = 10_000 - def perform + attr_reader :shard_name + + def perform(shard_name) + @shard_name = shard_name + return unless Gitlab::CurrentSettings.repository_checks_enabled + return unless Gitlab::ShardHealthCache.healthy_shard?(shard_name) start = Time.now @@ -37,18 +44,22 @@ module RepositoryCheck end def never_checked_project_ids(batch_size) - Project.where(last_repository_check_at: nil) + projects_on_shard.where(last_repository_check_at: nil) .where('created_at < ?', 24.hours.ago) .limit(batch_size).pluck(:id) end def old_checked_project_ids(batch_size) - Project.where.not(last_repository_check_at: nil) + projects_on_shard.where.not(last_repository_check_at: nil) .where('last_repository_check_at < ?', 1.month.ago) .reorder(last_repository_check_at: :asc) .limit(batch_size).pluck(:id) end + def projects_on_shard + Project.where(repository_storage: shard_name) + end + def try_obtain_lease(id) # Use a 24-hour timeout because on servers/projects where 'git fsck' is # super slow we definitely do not want to run it twice in parallel. diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb index 97b89dc3db5..81e1a4b63bb 100644 --- a/app/workers/repository_check/clear_worker.rb +++ b/app/workers/repository_check/clear_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RepositoryCheck class ClearWorker include ApplicationWorker diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb new file mode 100644 index 00000000000..891a273afd7 --- /dev/null +++ b/app/workers/repository_check/dispatch_worker.rb @@ -0,0 +1,15 @@ +module RepositoryCheck + class DispatchWorker + include ApplicationWorker + include CronjobQueue + include ::EachShardWorker + + def perform + return unless Gitlab::CurrentSettings.repository_checks_enabled + + each_eligible_shard do |shard_name| + RepositoryCheck::BatchWorker.perform_async(shard_name) + end + end + end +end diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb index 3cffb8b14e4..f44e5693b25 100644 --- a/app/workers/repository_check/single_repository_worker.rb +++ b/app/workers/repository_check/single_repository_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RepositoryCheck class SingleRepositoryWorker include ApplicationWorker diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 08b1c3a7d7a..5ef9b744db3 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RepositoryForkWorker include ApplicationWorker include Gitlab::ShellAdapter @@ -8,27 +10,12 @@ class RepositoryForkWorker target_project_id = args.shift target_project = Project.find(target_project_id) - # By v10.8, we should've drained the queue of all jobs using the old arguments. - # We can remove the else clause if we're no longer logging the message in that clause. - # See https://gitlab.com/gitlab-org/gitaly/issues/1110 - if args.empty? - source_project = target_project.forked_from_project - unless source_project - return target_project.mark_import_as_failed('Source project cannot be found.') - end - - fork_repository(target_project, source_project.repository_storage, source_project.disk_path) - else - Rails.logger.info("Project #{target_project.id} is being forked using old-style arguments.") - - source_repository_storage_path, source_disk_path = *args - - source_repository_storage_name = Gitlab.config.repositories.storages.find do |_, info| - info.legacy_disk_path == source_repository_storage_path - end&.first || raise("no shard found for path '#{source_repository_storage_path}'") - - fork_repository(target_project, source_repository_storage_name, source_disk_path) + source_project = target_project.forked_from_project + unless source_project + return target_project.mark_import_as_failed('Source project cannot be found.') end + + fork_repository(target_project, source_project.repository_storage, source_project.disk_path) end private diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index d79b5ee5346..25fec542ac7 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RepositoryImportWorker include ApplicationWorker include ExceptionBacktrace diff --git a/app/workers/repository_remove_remote_worker.rb b/app/workers/repository_remove_remote_worker.rb index 1c19b604b77..a85e9fa9394 100644 --- a/app/workers/repository_remove_remote_worker.rb +++ b/app/workers/repository_remove_remote_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RepositoryRemoveRemoteWorker include ApplicationWorker include ExclusiveLeaseGuard diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index bb963979e88..9d4e67deb9c 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RepositoryUpdateRemoteMirrorWorker UpdateAlreadyInProgressError = Class.new(StandardError) UpdateError = Class.new(StandardError) diff --git a/app/workers/requests_profiles_worker.rb b/app/workers/requests_profiles_worker.rb index 55c236e9e9d..ae022d43e29 100644 --- a/app/workers/requests_profiles_worker.rb +++ b/app/workers/requests_profiles_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RequestsProfilesWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 8f5138fc873..1f6cb18c812 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class RunPipelineScheduleWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/schedule_update_user_activity_worker.rb b/app/workers/schedule_update_user_activity_worker.rb index d9376577597..ff42fb8f0e5 100644 --- a/app/workers/schedule_update_user_activity_worker.rb +++ b/app/workers/schedule_update_user_activity_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ScheduleUpdateUserActivityWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index e4b683fca33..ec8c8e3689f 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StageUpdateWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/storage_migrator_worker.rb b/app/workers/storage_migrator_worker.rb index f92421a667d..fa76fbac55c 100644 --- a/app/workers/storage_migrator_worker.rb +++ b/app/workers/storage_migrator_worker.rb @@ -1,29 +1,10 @@ +# frozen_string_literal: true + class StorageMigratorWorker include ApplicationWorker - BATCH_SIZE = 100 - def perform(start, finish) - projects = build_relation(start, finish) - - projects.with_route.find_each(batch_size: BATCH_SIZE) do |project| - Rails.logger.info "Starting storage migration of #{project.full_path} (ID=#{project.id})..." - - begin - project.migrate_to_hashed_storage! - rescue => err - Rails.logger.error("#{err.message} migrating storage of #{project.full_path} (ID=#{project.id}), trace - #{err.backtrace}") - end - end - end - - def build_relation(start, finish) - relation = Project - table = Project.arel_table - - relation = relation.where(table[:id].gteq(start)) if start - relation = relation.where(table[:id].lteq(finish)) if finish - - relation + migrator = Gitlab::HashedStorage::Migrator.new + migrator.bulk_migrate(start, finish) end end diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 7ebf69bdc39..c78b7fac589 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StuckCiJobsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index 6fdd7592e74..79ce06dd66e 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StuckImportJobsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index 16394293c79..b0a62f76e94 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StuckMergeJobsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/system_hook_push_worker.rb b/app/workers/system_hook_push_worker.rb index ceeaaf8d189..15e369ebcfb 100644 --- a/app/workers/system_hook_push_worker.rb +++ b/app/workers/system_hook_push_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SystemHookPushWorker include ApplicationWorker diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb index 7eb65452a7d..3297a1fe3d0 100644 --- a/app/workers/trending_projects_worker.rb +++ b/app/workers/trending_projects_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TrendingProjectsWorker include ApplicationWorker include CronjobQueue diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 76f84ff920f..0487a393566 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UpdateHeadPipelineForMergeRequestWorker include ApplicationWorker include PipelineQueue diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 74bb9993275..742841219b3 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UpdateMergeRequestsWorker include ApplicationWorker diff --git a/app/workers/update_user_activity_worker.rb b/app/workers/update_user_activity_worker.rb index 27ec5cd33fb..15f01a70337 100644 --- a/app/workers/update_user_activity_worker.rb +++ b/app/workers/update_user_activity_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UpdateUserActivityWorker include ApplicationWorker diff --git a/app/workers/upload_checksum_worker.rb b/app/workers/upload_checksum_worker.rb index 65d40336f18..2a0536106d7 100644 --- a/app/workers/upload_checksum_worker.rb +++ b/app/workers/upload_checksum_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class UploadChecksumWorker include ApplicationWorker diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb index 19cdb279aaa..8aa1d9290fd 100644 --- a/app/workers/wait_for_cluster_creation_worker.rb +++ b/app/workers/wait_for_cluster_creation_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WaitForClusterCreationWorker include ApplicationWorker include ClusterQueue diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index dfc3f33ad9d..09219a24a16 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class WebHookWorker include ApplicationWorker |