diff options
author | Sean McGivern <sean@gitlab.com> | 2018-07-06 11:41:03 +0100 |
---|---|---|
committer | Sean McGivern <sean@gitlab.com> | 2018-07-06 11:41:03 +0100 |
commit | 827712e78ebb645bc7250f927de99c5f3395368f (patch) | |
tree | bb29b6bfc776ed5b99ab84918c33708e12df64ea /app | |
parent | 0d9ef34a2541a2adf00677132eac3637de33b6d4 (diff) | |
parent | b0fa01fce3822da94aee6264829841996beb6df3 (diff) | |
download | gitlab-ce-827712e78ebb645bc7250f927de99c5f3395368f.tar.gz |
Merge branch 'master' into satishperala/gitlab-ce-20720_webhooks_full_image_url
Diffstat (limited to 'app')
703 files changed, 10215 insertions, 4360 deletions
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/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 1ea6dd909e9..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'; @@ -321,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(); @@ -338,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_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 7920e08e4d8..a2355d7fd5c 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,4 +1,4 @@ -/* eslint-disable comma-dangle, space-before-function-paren, one-var */ +/* eslint-disable comma-dangle */ import Sortable from 'sortablejs'; import Vue from 'vue'; diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index b7d3574bc80..0398102ad02 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,78 +1,78 @@ <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> diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js index 4dd9aebeed9..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'; diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 82fe6b0c5fb..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; @@ -22,10 +22,10 @@ window.gl.issueBoards = window.gl.issueBoards || {}; gl.issueBoards.BoardSidebar = Vue.extend({ components: { - assigneeTitle, - assignees, - removeBtn: gl.issueBoards.RemoveIssueBtn, - subscriptions, + AssigneeTitle, + Assignees, + RemoveBtn, + Subscriptions, }, props: { currentUser: { 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 f7d7b910e2f..00000000000 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ /dev/null @@ -1,196 +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({ - components: { - UserAvatarLink, - }, - 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, - }; - }, - }, - 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 888bc9d7ef2..dbd69f84526 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.js +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,8 +1,8 @@ -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], props: { newIssuePath: { @@ -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 2745ca219ad..d4affc8c3de 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -1,14 +1,14 @@ -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: { - 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, + ListsDropdown, }, mixins: [modalMixin], data() { @@ -28,23 +28,29 @@ gl.issueBoards.ModalFooter = Vue.extend({ }, }, methods: { + buildUpdateRequest(list) { + return { + add_label_ids: [list.label.id], + }; + }, addIssues() { const firstListIndex = 1; const list = this.modal.selectedList || this.state.lists[firstListIndex]; const selectedIssues = ModalStore.getSelectedIssues(); const issueIds = selectedIssues.map(issue => issue.id); + const req = this.buildUpdateRequest(list); // Post the data to the backend - gl.boardService.bulkUpdate(issueIds, { - add_label_ids: [list.label.id], - }).catch(() => { - Flash(__('Failed to update issues, please try again.')); + gl.boardService + .bulkUpdate(issueIds, req) + .catch(() => { + Flash(__('Failed to update issues, please try again.')); - selectedIssues.forEach((issue) => { - list.removeIssue(issue); - list.issuesSize -= 1; + selectedIssues.forEach((issue) => { + list.removeIssue(issue); + list.issuesSize -= 1; + }); }); - }); // Add the issues on the frontend selectedIssues.forEach((issue) => { @@ -55,28 +61,32 @@ gl.issueBoards.ModalFooter = Vue.extend({ this.toggleModal(false); }, }, - 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 5e511bb8935..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({ - components: { - 'modal-tabs': gl.issueBoards.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(); - }, - }, - 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 c8b2f45f177..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({ - components: { - 'modal-header': gl.issueBoards.ModalHeader, - 'modal-list': gl.issueBoards.ModalList, - 'modal-footer': gl.issueBoards.ModalFooter, - 'empty-state': gl.issueBoards.ModalEmptyState, - 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 - }); - }, - }, - 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 11061c72a7b..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({ - components: { - 'issue-card-inner': gl.issueBoards.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; - } - }, - }, - 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..a58b5afe970 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -0,0 +1,161 @@ +<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 6dcd4aaec43..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'; 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..90d4c710daf --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -0,0 +1,91 @@ +<script> + import Vue from 'vue'; + import Flash from '../../../flash'; + import { __ } from '../../../locale'; + + const Store = gl.issueBoards.BoardsStore; + + export default Vue.extend({ + 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 req = this.buildPatchRequest(issue, lists); + + const data = { + issue: this.seedPatchRequest(issue, req), + }; + + if (data.issue.label_ids.length === 0) { + data.issue.label_ids = ['']; + } + + // 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 = {}; + }, + /** + * Build the default patch request. + */ + buildPatchRequest(issue, lists) { + const listLabelIds = lists.map(list => list.label.id); + + const labelIds = issue.labels + .map(label => label.id) + .filter(id => !listLabelIds.includes(id)); + + return { + label_ids: labelIds, + }; + }, + /** + * Seed the given patch request. + * + * (This is overridden in EE) + */ + seedPatchRequest(issue, req) { + return req; + }, + }, + }); +</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 cdad8d238e3..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'; @@ -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/issue.js b/app/assets/javascripts/boards/models/issue.js index b381d48d625..c7cfb72067c 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -1,9 +1,10 @@ -/* 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 */ import Vue from 'vue'; +import '~/vue_shared/models/label'; import IssueProject from './project'; class ListIssue { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 1f0fe7f9e85..4f05a0e4282 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,4 +1,4 @@ -/* 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 */ import ListLabel from '~/vue_shared/models/label'; @@ -7,6 +7,24 @@ import queryData from '../utils/query_data'; const PER_PAGE = 20; +const TYPES = { + backlog: { + isPreset: true, + isExpandable: true, + isBlank: false, + }, + closed: { + isPreset: true, + isExpandable: true, + isBlank: false, + }, + blank: { + isPreset: true, + isExpandable: false, + isBlank: true, + }, +}; + class List { constructor(obj, defaultAvatar) { this.id = obj.id; @@ -14,8 +32,10 @@ class List { this.position = obj.position; this.title = obj.title; this.type = obj.list_type; - this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1; - this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1; + + const typeInfo = this.getTypeInfo(this.type); + this.preset = !!typeInfo.isPreset; + this.isExpandable = !!typeInfo.isExpandable; this.isExpanded = true; this.page = 1; this.loading = true; @@ -31,7 +51,7 @@ class List { this.title = this.assignee.name; } - if (this.type !== 'blank' && this.id) { + if (!typeInfo.isBlank && this.id) { this.getIssues().catch(() => { // TODO: handle request error }); @@ -107,7 +127,7 @@ class List { return gl.boardService .getIssuesForList(this.id, data) .then(res => res.data) - .then((data) => { + .then(data => { this.loading = false; this.issuesSize = data.size; @@ -126,18 +146,7 @@ class List { return gl.boardService .newIssue(this.id, issue) .then(res => res.data) - .then((data) => { - issue.id = data.id; - issue.iid = data.iid; - issue.project = data.project; - issue.path = data.real_path; - issue.referencePath = data.reference_path; - - if (this.issuesSize > 1) { - const moveBeforeId = this.issues[1].id; - gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId); - } - }); + .then(data => this.onNewIssueResponse(issue, data)); } createIssues(data) { @@ -217,6 +226,25 @@ class List { return !matchesRemove; }); } + + getTypeInfo (type) { + return TYPES[type] || {}; + } + + onNewIssueResponse (issue, data) { + issue.id = data.id; + issue.iid = data.iid; + issue.project = data.project; + issue.path = data.real_path; + issue.referencePath = data.reference_path; + + if (this.issuesSize > 1) { + const moveBeforeId = this.issues[1].id; + gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId); + } + } } window.List = List; + +export default List; 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/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index ffe86468b12..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'; 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/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/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 f77a5730b77..02aa507ba03 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -281,7 +281,7 @@ 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 = 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 40f7c2fe5f3..5528d2a542b 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -111,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) 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 2ce4b050763..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,11 +1,10 @@ -/* 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], @@ -74,7 +73,7 @@ const JumpToDiscussion = Vue.extend({ }).toArray(); }; - const discussions = this.discussions; + const { discussions } = this; if (activeTab === 'diffs') { discussionsSelector = '.diffs .notes[data-discussion-id]'; diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 07f3be29090..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 */ @@ -41,54 +40,54 @@ const ResolveBtn = Vue.extend({ required: true, }, }, - data: function () { + data() { return { discussions: CommentsStore.state, - loading: false + loading: false, }; }, 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': { + discussions: { handler: 'updateTooltip', - deep: true - } + deep: true, + }, }, - mounted: function () { + mounted() { $(this.$refs.button).tooltip({ - container: 'body' + container: 'body', }); }, - beforeDestroy: function () { + beforeDestroy() { CommentsStore.delete(this.discussionId, this.noteId); }, - created: function () { + created() { CommentsStore.create({ discussionId: this.discussionId, noteId: this.noteId, @@ -101,43 +100,41 @@ const ResolveBtn = Vue.extend({ }); }, 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.')); - } + .catch( + () => new Flash('An error occurred when trying to resolve a comment. Please try again.'), + ); + }, }, }); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js index 9f613410e81..e2683e09f40 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_count.js +++ b/app/assets/javascripts/diff_notes/components/resolve_count.js @@ -1,10 +1,9 @@ -/* 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], 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 210a00c5fc2..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 */ diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index d5161ab7df9..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,15 +69,5 @@ export default () => { gl.diffNotesCompileComponents(); - const resolveCountAppEl = document.querySelector('#resolve-count-app'); - if (!hasVueMRDiscussionsCookie() && resolveCountAppEl) { - new Vue({ - el: resolveCountAppEl, - 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..b6af49c7e2e --- /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-show="isInlineView" + :diff-file="diffFile" + :diff-lines="diffFile.highlightedDiffLines || []" + /> + <parallel-diff-view + v-show="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..a8e8732053b --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -0,0 +1,262 @@ +<script> +import _ from 'underscore'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileIcon from '~/vue_shared/components/file_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, + FileIcon, + }, + 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 append-right-5" + @click.stop="handleToggle" + /> + <a + ref="titleWrapper" + :href="titleLink" + class="append-right-4" + > + <file-icon + :file-name="filePath" + :size="18" + aria-hidden="true" + css-classes="js-file-icon append-right-5" + /> + <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..5b08b161114 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -0,0 +1,145 @@ +<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, + INLINE_DIFF_VIEW_TYPE, + LINE_POSITION_LEFT, + LINE_POSITION_RIGHT, +} from '../constants'; + +export default { + components: { + DiffLineGutterContent, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFile: { + type: Object, + required: true, + }, + diffViewType: { + type: String, + required: false, + default: INLINE_DIFF_VIEW_TYPE, + }, + 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']), + normalizedLine() { + let normalizedLine; + + if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) { + normalizedLine = this.line; + } else if (this.linePosition === LINE_POSITION_LEFT) { + normalizedLine = this.line.left; + } else if (this.linePosition === LINE_POSITION_RIGHT) { + normalizedLine = this.line.right; + } + + return normalizedLine; + }, + isMatchLine() { + return this.normalizedLine.type === MATCH_LINE_TYPE; + }, + isContextLine() { + return this.normalizedLine.type === CONTEXT_LINE_TYPE; + }, + isMetaLine() { + const { type } = this.normalizedLine; + + return ( + type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE || 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/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_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue new file mode 100644 index 00000000000..a2470843ca6 --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -0,0 +1,104 @@ +<script> +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, + PARALLEL_DIFF_VIEW_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, + }; + }, + computed: { + ...mapGetters(['isInlineView']), + isContextLine() { + return 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}`; + }, + }, + created() { + this.newLineType = NEW_LINE_TYPE; + this.oldLineType = OLD_LINE_TYPE; + this.linePositionLeft = LINE_POSITION_LEFT; + this.linePositionRight = LINE_POSITION_RIGHT; + }, + methods: { + handleMouseMove(e) { + // To show the comment icon on the gutter we need to know if we hover the line. + // Current table structure doesn't allow us to do this with CSS in both of the diff view types + this.isHover = e.type === 'mouseover'; + }, + }, +}; +</script> + +<template> + <tr + :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> +</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..b884230fb63 --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -0,0 +1,65 @@ +<script> +import { mapGetters } from 'vuex'; +import inlineDiffTableRow from './inline_diff_table_row.vue'; +import inlineDiffCommentRow from './inline_diff_comment_row.vue'; +import { trimFirstCharOfLineContent } from '../store/utils'; + +export default { + components: { + inlineDiffCommentRow, + inlineDiffTableRow, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + }, + computed: { + ...mapGetters(['commit']), + normalizedDiffLines() { + return this.diffLines.map(line => (line.richText ? trimFirstCharOfLineContent(line) : line)); + }, + diffLinesLength() { + return this.normalizedDiffLines.length; + }, + commitId() { + return this.commit && this.commit.id; + }, + userColorScheme() { + return window.gon.user_color_scheme; + }, + }, +}; +</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" + > + <inline-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_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue new file mode 100644 index 00000000000..eb769584d74 --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -0,0 +1,150 @@ +<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 { + isLeftHover: false, + isRightHover: false, + }; + }, + computed: { + ...mapGetters(['isParallelView']), + isContextLine() { + return this.line.left.type === CONTEXT_LINE_TYPE; + }, + classNameMap() { + return { + [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, + [PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView, + }; + }, + 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; + this.parallelDiffViewType = PARALLEL_DIFF_VIEW_TYPE; + }, + methods: { + handleMouseMove(e) { + const isHover = e.type === 'mouseover'; + 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 + :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" + :diff-view-type="parallelDiffViewType" + class="diff-line-num old_line" + /> + <diff-table-cell + :id="line.left.lineCode" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + :line-position="linePositionLeft" + :line-type="parallelViewLeftLineType" + :diff-view-type="parallelDiffViewType" + 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" + :diff-view-type="parallelDiffViewType" + class="diff-line-num new_line" + /> + <diff-table-cell + :id="line.right.lineCode" + :diff-file="diffFile" + :line="line" + :is-content-line="true" + :line-position="linePositionRight" + :line-type="line.right.type" + :diff-view-type="parallelDiffViewType" + class="line_content parallel right-side" + @mousedown.native="handleParallelLineMouseDown" + /> + </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..52561e197e6 --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -0,0 +1,85 @@ +<script> +import { mapGetters } from 'vuex'; +import parallelDiffTableRow from './parallel_diff_table_row.vue'; +import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; +import { EMPTY_CELL_TYPE } from '../constants'; +import { trimFirstCharOfLineContent } from '../store/utils'; + +export default { + components: { + parallelDiffTableRow, + parallelDiffCommentRow, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + }, + computed: { + ...mapGetters(['commit']), + parallelDiffLines() { + return this.diffLines.map(line => { + const parallelLine = Object.assign({}, line); + + if (line.left) { + parallelLine.left = trimFirstCharOfLineContent(line.left); + } else { + parallelLine.left = { type: EMPTY_CELL_TYPE }; + } + + if (line.right) { + parallelLine.right = trimFirstCharOfLineContent(line.right); + } else { + parallelLine.right = { type: EMPTY_CELL_TYPE }; + } + + return parallelLine; + }); + }, + diffLinesLength() { + return this.parallelDiffLines.length; + }, + commitId() { + return this.commit && this.commit.id; + }, + userColorScheme() { + return window.gon.user_color_scheme; + }, + }, +}; +</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" + > + <parallel-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/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..2c8e1a1466f --- /dev/null +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -0,0 +1,10 @@ +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_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..8aa8a114c6f --- /dev/null +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -0,0 +1,80 @@ +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_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..d9589baa76e --- /dev/null +++ b/app/assets/javascripts/diffs/store/utils.js @@ -0,0 +1,175 @@ +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); + } +} + +/** + * Trims the first char of the `richText` property when it's either a space or a diff symbol. + * @param {Object} line + * @returns {Object} + */ +export function trimFirstCharOfLineContent(line = {}) { + const parsedLine = Object.assign({}, line); + + if (line.richText) { + const firstChar = parsedLine.richText.charAt(0); + + if (firstChar === ' ' || firstChar === '+' || firstChar === '-') { + parsedLine.richText = line.richText.substring(1); + } + } + + return parsedLine; +} 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/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 866e91057ec..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 ''; 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/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 7fbba7e27cb..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'; @@ -613,7 +613,7 @@ GitLabDropdown = (function() { }; 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; @@ -651,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}']`); @@ -705,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>"; @@ -721,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/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 1def38bb336..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, }) 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/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 955d9280728..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() { @@ -117,7 +120,7 @@ export default { class="btn btn-primary btn-sm btn-block" @click="toggleIsSmall" > - {{ __('Commit') }} + {{ __('Commit…') }} </button> <p class="text-center" @@ -136,14 +139,14 @@ 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="__('Commit')" + :label="commitButtonText" container-class="btn btn-success btn-sm float-left" @click="commitChanges" /> 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 5cda7967130..ee21eeda3cd 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -89,14 +89,14 @@ export default { <template> <div class="multi-file-commit-list-item position-relative"> - <button + <div v-tooltip :title="tooltipTitle" :class="{ 'is-active': isActive }" - type="button" class="multi-file-commit-list-path w-100 border-0 ml-0 mr-0" + role="button" @dblclick="fileAction" @click="openFileInEditor" > @@ -107,7 +107,7 @@ export default { :css-classes="iconClass" />{{ file.name }} </span> - </button> + </div> <component :is="actionComponent" :path="file.path" 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 40496c80a46..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 { @@ -114,7 +118,7 @@ export default { </div> <textarea ref="textarea" - :placeholder="__('Write a commit message...')" + :placeholder="placeholder" :value="text" class="note-textarea ide-commit-message-textarea" name="commit-message" 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 35ab3fd11df..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: { 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/file_finder/item.vue b/app/assets/javascripts/ide/components/file_finder/item.vue index a4cf3edb981..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 diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index f5f7f967a92..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" > diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 1814924be39..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]; } diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index dedc2988618..5cd2c9ce188 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -69,7 +69,7 @@ export default { > <icon :size="16" - name="pipeline" + name="rocket" /> </button> </li> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index c2c678ff0be..50ab242ba2a 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -28,7 +28,7 @@ export default { ]), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']), - ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), + ...mapGetters('commit', ['discardDraftButtonDisabled']), showStageUnstageArea() { return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal); }, diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index c34547fcc60..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> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 1ad52c1bd83..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) { 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/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js index f09930e8158..78b2eab6399 100644 --- a/app/assets/javascripts/ide/lib/diff/diff_worker.js +++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js @@ -2,7 +2,7 @@ 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({ diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index da9de25302a..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', viewer: 'none' } }); + 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 edb20ff96fc..4aa151abcb7 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,17 +1,16 @@ -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, @@ -21,7 +20,15 @@ export const getMergeRequestData = ( 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 { @@ -30,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, @@ -47,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 { @@ -56,7 +70,7 @@ export const getMergeRequestChanges = ( }); export const getMergeRequestVersions = ( - { commit, state }, + { commit, dispatch, state }, { projectId, mergeRequestId, force = false } = {}, ) => new Promise((resolve, reject) => { @@ -73,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 0b99bce4a8e..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) => { @@ -32,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' || @@ -51,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 { @@ -80,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 7219abc4185..7828c31f20e 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,7 +1,6 @@ import $ from 'jquery'; import { sprintf, __ } from '~/locale'; import flash from '~/flash'; -import { stripHtml } from '~/lib/utils/text_utility'; import * as rootTypes from '../../mutation_types'; import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; import router from '../../../ide_router'; @@ -103,17 +102,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({ - branch: getters.branchName, - newBranch, - state, - rootState, - }); + const stageFilesPromise = rootState.stagedFiles.length + ? Promise.resolve() + : dispatch('stageAllChanges', null, { root: true }); commit(types.UPDATE_LOADING, true); - return 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); @@ -191,11 +197,18 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo 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); + dispatch( + 'setErrorMessage', + { + text: __('An error accured whilst committing your changes.'), + action: () => + dispatch('commitChanges').then(() => + dispatch('setErrorMessage', null, { root: true }), + ), + actionText: __('Please try again'), + }, + { root: true }, + ); window.dispatchEvent(new Event('resize')); } 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 551dd322c9b..cdd8076952f 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js @@ -1,6 +1,5 @@ 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'; @@ -8,8 +7,20 @@ import * as rootTypes from '../../mutation_types'; export const requestMergeRequests = ({ commit }, type) => commit(types.REQUEST_MERGE_REQUESTS, type); -export const receiveMergeRequestsError = ({ commit }, type) => { - flash(__('Error loading merge requests.')); +export const receiveMergeRequestsError = ({ commit, dispatch }, { type, search }) => { + dispatch( + 'setErrorMessage', + { + text: __('Error loading merge requests.'), + action: payload => + dispatch('fetchMergeRequests', payload).then(() => + dispatch('setErrorMessage', null, { root: true }), + ), + actionText: __('Please try again'), + actionPayload: { type, search }, + }, + { root: true }, + ); commit(types.RECEIVE_MERGE_REQUESTS_ERROR, type); }; export const receiveMergeRequestsSuccess = ({ commit }, { type, data }) => @@ -22,7 +33,7 @@ export const fetchMergeRequests = ({ dispatch, state: { state } }, { type, searc Api.mergeRequests({ scope, state, search }) .then(({ data }) => dispatch('receiveMergeRequestsSuccess', { type, data })) - .catch(() => dispatch('receiveMergeRequestsError', type)); + .catch(() => dispatch('receiveMergeRequestsError', { type, search })); }; export const resetMergeRequests = ({ commit }, type) => commit(types.RESET_MERGE_REQUESTS, type); diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index fe1dc9ac8f8..8cb01f25223 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -1,7 +1,7 @@ import Visibility from 'visibilityjs'; import axios from 'axios'; +import httpStatus from '../../../../lib/utils/http_status'; import { __ } from '../../../../locale'; -import flash from '../../../../flash'; import Poll from '../../../../lib/utils/poll'; import service from '../../../services'; import { rightSidebarViews } from '../../../constants'; @@ -18,10 +18,27 @@ export const stopPipelinePolling = () => { export const restartPipelinePolling = () => { if (eTagPoll) eTagPoll.restart(); }; +export const forcePipelineRequest = () => { + if (eTagPoll) eTagPoll.makeRequest(); +}; export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE); -export const receiveLatestPipelineError = ({ commit, dispatch }) => { - flash(__('There was an error loading latest pipeline')); +export const receiveLatestPipelineError = ({ commit, dispatch }, err) => { + if (err.status !== httpStatus.NOT_FOUND) { + dispatch( + 'setErrorMessage', + { + text: __('An error occured whilst fetching the latest pipline.'), + action: () => + dispatch('forcePipelineRequest').then(() => + dispatch('setErrorMessage', null, { root: true }), + ), + actionText: __('Please try again'), + actionPayload: null, + }, + { root: true }, + ); + } commit(types.RECEIVE_LASTEST_PIPELINE_ERROR); dispatch('stopPipelinePolling'); }; @@ -46,7 +63,7 @@ export const fetchLatestPipeline = ({ dispatch, rootGetters }) => { method: 'lastCommitPipelines', data: { getters: rootGetters }, successCallback: ({ data }) => dispatch('receiveLatestPipelineSuccess', data), - errorCallback: () => dispatch('receiveLatestPipelineError'), + errorCallback: err => dispatch('receiveLatestPipelineError', err), }); if (!Visibility.hidden()) { @@ -63,9 +80,21 @@ export const fetchLatestPipeline = ({ dispatch, rootGetters }) => { }; export const requestJobs = ({ commit }, id) => commit(types.REQUEST_JOBS, id); -export const receiveJobsError = ({ commit }, id) => { - flash(__('There was an error loading jobs')); - commit(types.RECEIVE_JOBS_ERROR, id); +export const receiveJobsError = ({ commit, dispatch }, stage) => { + dispatch( + 'setErrorMessage', + { + text: __('An error occured whilst loading the pipelines jobs.'), + action: payload => + dispatch('fetchJobs', payload).then(() => + dispatch('setErrorMessage', null, { root: true }), + ), + actionText: __('Please try again'), + actionPayload: stage, + }, + { root: true }, + ); + commit(types.RECEIVE_JOBS_ERROR, stage.id); }; export const receiveJobsSuccess = ({ commit }, { id, data }) => commit(types.RECEIVE_JOBS_SUCCESS, { id, data }); @@ -76,7 +105,7 @@ export const fetchJobs = ({ dispatch }, stage) => { axios .get(stage.dropdownPath) .then(({ data }) => dispatch('receiveJobsSuccess', { id: stage.id, data })) - .catch(() => dispatch('receiveJobsError', stage.id)); + .catch(() => dispatch('receiveJobsError', stage)); }; export const toggleStageCollapsed = ({ commit }, stageId) => @@ -90,8 +119,18 @@ export const setDetailJob = ({ commit, dispatch }, job) => { }; export const requestJobTrace = ({ commit }) => commit(types.REQUEST_JOB_TRACE); -export const receiveJobTraceError = ({ commit }) => { - flash(__('Error fetching job trace')); +export const receiveJobTraceError = ({ commit, dispatch }) => { + dispatch( + 'setErrorMessage', + { + text: __('An error occured whilst fetching the job trace.'), + action: () => + dispatch('fetchJobTrace').then(() => dispatch('setErrorMessage', null, { root: true })), + actionText: __('Please try again'), + actionPayload: null, + }, + { root: true }, + ); commit(types.RECEIVE_JOB_TRACE_ERROR); }; export const receiveJobTraceSuccess = ({ commit }, data) => diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 99b315ac4db..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'; @@ -71,3 +72,5 @@ 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 48f1da4eccf..702be2140e2 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -163,6 +163,9 @@ export default { [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/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 10368a4d97c..9e6b86dd844 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -105,9 +105,9 @@ 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, 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/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/job.js b/app/assets/javascripts/job.js index fc13f467675..d4f2a3ef7d3 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -164,7 +164,7 @@ export default class Job extends LogOutputBehaviours { // 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/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 8b01024b7d4..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'; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 7d0ff53f366..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; }); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index d0b0e5e1ba1..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); @@ -212,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 || @@ -225,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 @@ -241,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]; }); @@ -255,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]; }); @@ -295,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; + }, {}); }; /** @@ -312,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 @@ -322,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); }; @@ -371,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) { @@ -447,7 +483,8 @@ export const resetFavicon = () => { }; export const setCiStatusFavicon = pageUrl => - axios.get(pageUrl) + axios + .get(pageUrl) .then(({ data }) => { if (data && data.favicon) { return setFaviconOverlay(data.favicon); @@ -469,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 7cca32dc6fa..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. @@ -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>', }); } @@ -275,10 +275,8 @@ export const totalDaysInMonth = date => { * * @param {Array} quarter */ -export const totalDaysInQuarter = quarter => quarter.reduce( - (acc, month) => acc + totalDaysInMonth(month), - 0, -); +export const totalDaysInQuarter = quarter => + quarter.reduce((acc, month) => acc + totalDaysInMonth(month), 0); /** * Returns list of Dates referring to Sundays of the month @@ -333,14 +331,8 @@ export const getTimeframeWindowFrom = (startDate, length) => { // Iterate and set date for the size of length // and push date reference to timeframe list const timeframe = new Array(length) - .fill() - .map( - (val, i) => new Date( - startDate.getFullYear(), - startDate.getMonth() + i, - 1, - ), - ); + .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])); @@ -362,14 +354,15 @@ export const getTimeframeWindowFrom = (startDate, length) => { * @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); +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 = { 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/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 f086d962221..afbab59055b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -13,7 +13,7 @@ export function formatRelevantDigits(number) { let relevantDigits = 0; let formattedNumber = ''; if (!Number.isNaN(Number(number))) { - digitsLeft = number.toString().split('.')[0]; + [digitsLeft] = number.toString().split('.'); switch (digitsLeft.length) { case 1: relevantDigits = 3; 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/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 303c5d8a894..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'; @@ -142,14 +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 = []; + const results = []; + const ref = range[0] <= range[1] ? range : range.reverse(); - // eslint-disable-next-line no-multi-assign - for (lineNumber = i = ref = range[0], ref1 = range[1]; ref <= ref1 ? i <= ref1 : i >= ref1; lineNumber = ref <= ref1 ? (i += 1) : (i -= 1)) { + for (let lineNumber = range[0]; lineNumber <= ref[1]; lineNumber += 1) { results.push(this.highlightLine(lineNumber)); } + return results; } else { return this.highlightLine(range[0]); 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 e4ed8111824..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'; 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 57e73e38d88..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,4 +1,4 @@ -/* eslint-disable no-param-reassign, comma-dangle */ +/* eslint-disable no-param-reassign */ import Vue from 'vue'; import actionsMixin from '../mixins/line_conflict_actions'; 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 491858c3602..7badd68089c 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -12,7 +12,7 @@ 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 { mergeConflictsStore } = gl.mergeConflicts; const mergeConflictsService = new MergeConflictsService({ conflictsPath: conflictsEl.dataset.conflictsPath, resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath, 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 65ab41559be..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, @@ -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_select.js b/app/assets/javascripts/milestone_select.js index 334279137d8..640a4c8260f 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -1,10 +1,11 @@ -/* 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 */ import $ from 'jquery'; import _ from 'underscore'; import { __ } from '~/locale'; +import '~/gl_dropdown'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; import ModalStore from './boards/stores/modal_store'; @@ -251,3 +252,5 @@ export default class MilestoneSelect { }); } } + +window.MilestoneSelect = MilestoneSelect; diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index e1c8b6a6d4a..17a6d5bcd2a 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,5 +1,7 @@ <script> import _ from 'underscore'; +import { s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import MonitoringService from '../services/monitoring_service'; import GraphGroup from './graph_group.vue'; @@ -13,6 +15,7 @@ export default { Graph, GraphGroup, EmptyState, + Icon, }, props: { hasMetrics: { @@ -80,6 +83,14 @@ export default { type: String, required: true, }, + environmentsEndpoint: { + type: String, + required: true, + }, + currentEnvironmentName: { + type: String, + required: true, + }, }, data() { return { @@ -96,6 +107,7 @@ export default { this.service = new MonitoringService({ metricsEndpoint: this.metricsEndpoint, deploymentEndpoint: this.deploymentEndpoint, + environmentsEndpoint: this.environmentsEndpoint, }); eventHub.$on('toggleAspectRatio', this.toggleAspectRatio); eventHub.$on('hoverChanged', this.hoverChanged); @@ -122,7 +134,11 @@ export default { this.service .getDeploymentData() .then(data => this.store.storeDeploymentData(data)) - .catch(() => new Flash('Error getting deployment information.')), + .catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))), + this.service + .getEnvironmentsData() + .then((data) => this.store.storeEnvironmentsData(data)) + .catch(() => Flash(s__('Metrics|There was an error getting environments information.'))), ]) .then(() => { if (this.store.groups.length < 1) { @@ -155,8 +171,41 @@ export default { <template> <div v-if="!showEmptyState" - class="prometheus-graphs" + class="prometheus-graphs prepend-top-10" > + <div class="environments d-flex align-items-center"> + {{ s__('Metrics|Environment') }} + <div class="dropdown prepend-left-10"> + <button + class="dropdown-menu-toggle" + data-toggle="dropdown" + type="button" + > + <span> + {{ currentEnvironmentName }} + </span> + <icon + name="chevron-down" + /> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> + <ul> + <li + v-for="environment in store.environmentsData" + :key="environment.latest.id" + > + <a + :href="environment.latest.metrics_path" + :class="{ 'is-active': environment.latest.name == currentEnvironmentName }" + class="dropdown-item" + > + {{ environment.latest.name }} + </a> + </li> + </ul> + </div> + </div> + </div> <graph-group v-for="(groupData, index) in store.groups" :key="index" diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js index 6fcca36d2fa..260d424378e 100644 --- a/app/assets/javascripts/monitoring/services/monitoring_service.js +++ b/app/assets/javascripts/monitoring/services/monitoring_service.js @@ -1,6 +1,7 @@ import axios from '../../lib/utils/axios_utils'; import statusCodes from '../../lib/utils/http_status'; import { backOff } from '../../lib/utils/common_utils'; +import { s__ } from '../../locale'; const MAX_REQUESTS = 3; @@ -23,9 +24,10 @@ function backOffRequest(makeRequestCallback) { } export default class MonitoringService { - constructor({ metricsEndpoint, deploymentEndpoint }) { + constructor({ metricsEndpoint, deploymentEndpoint, environmentsEndpoint }) { this.metricsEndpoint = metricsEndpoint; this.deploymentEndpoint = deploymentEndpoint; + this.environmentsEndpoint = environmentsEndpoint; } getGraphsData() { @@ -33,7 +35,7 @@ export default class MonitoringService { .then(resp => resp.data) .then((response) => { if (!response || !response.data) { - throw new Error('Unexpected metrics data response from prometheus endpoint'); + throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); } return response.data; }); @@ -47,9 +49,20 @@ export default class MonitoringService { .then(resp => resp.data) .then((response) => { if (!response || !response.deployments) { - throw new Error('Unexpected deployment data response from prometheus endpoint'); + throw new Error(s__('Metrics|Unexpected deployment data response from prometheus endpoint')); } return response.deployments; }); } + + getEnvironmentsData() { + return axios.get(this.environmentsEndpoint) + .then(resp => resp.data) + .then((response) => { + if (!response || !response.environments) { + throw new Error(s__('Metrics|There was an error fetching the environments data, please try again')); + } + return response.environments; + }); + } } diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 535c415cd6d..748b8cb6e6e 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -24,6 +24,7 @@ export default class MonitoringStore { constructor() { this.groups = []; this.deploymentData = []; + this.environmentsData = []; } storeMetrics(groups = []) { @@ -37,6 +38,10 @@ export default class MonitoringStore { this.deploymentData = deploymentData; } + storeEnvironmentsData(environmentsData = []) { + this.environmentsData = environmentsData; + } + getMetricsCount() { return this.groups.reduce((count, group) => count + group.metrics.length, 0); } diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index ed3a27dd68b..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); 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 e4096ddb00d..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({ @@ -113,8 +113,7 @@ export default (function() { }); ref = this.days; - // eslint-disable-next-line no-multi-assign - 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 @@ -122,7 +121,7 @@ export default (function() { font: "12px Monaco, monospace", fill: "#BBB" }); - cuday = day[0]; + [cuday] = day; } if (cumonth !== day[1]) { // Months @@ -130,6 +129,8 @@ export default (function() { font: "12px Monaco, monospace", fill: "#EEE" }); + + // eslint-disable-next-line prefer-destructuring cumonth = day[1]; } } @@ -170,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(); @@ -208,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) { @@ -243,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" @@ -263,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 @@ -283,13 +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 = []; + var arrow, color, i, len, offset, parent, parentCommit, parentX1, parentX2, parentY, route; + const { r } = this; + const ref = commit.parents; + const results = []; - // eslint-disable-next-line no-multi-assign - for (i = j = 0, len = ref.length; j < len; i = (j += 1)) { + 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; @@ -333,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/notes.js b/app/assets/javascripts/notes.js index 27c5dedcf0b..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) { @@ -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. */ @@ -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 ad6dd3d9a09..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 - v-else-if="isLocked(getNoteableData) && !canCreateNote" - issuable-type="issue" + v-else-if="!canCreateNote" + :issuable-type="issuableTypeTitle" /> <ul v-else-if="canCreateNote" @@ -357,7 +352,7 @@ Please check your network connection and try again.`; 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" @@ -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> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index cafb28910eb..9c2908c477e 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,60 +1,94 @@ <script> -import $ from 'jquery'; -import syntaxHighlight from '~/syntax_highlight'; -import imageDiffHelper from '~/image_diff/helpers/index'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import DiffFileHeader from './diff_file_header.vue'; + import { mapState, mapActions } from 'vuex'; + import imageDiffHelper from '~/image_diff/helpers/index'; + import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + 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, - }, - props: { - discussion: { - type: Object, - required: true, + export default { + components: { + DiffFileHeader, + SkeletonLoadingContainer, }, - }, - computed: { - isImageDiff() { - return !this.diffFile.text; + props: { + discussion: { + type: Object, + required: true, + }, }, - diffFileClass() { - const { text } = this.diffFile; - return text ? 'text-file' : 'js-image-file'; + data() { + return { + error: false, + }; }, - diffRows() { - return $(this.discussion.truncatedDiffLines); - }, - diffFile() { - return convertObjectPropsToCamelCase(this.discussion.diffFile); + 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; + }, + diffFileClass() { + const { text } = this.diffFile; + return text ? 'text-file' : 'js-image-file'; + }, + 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() { + if (this.discussion.truncatedDiffLines) { + return this.discussion.truncatedDiffLines.map(line => + trimFirstCharOfLineContent(convertObjectPropsToCamelCase(line)), + ); + } + + return []; + }, }, - imageDiffHtml() { - return this.discussion.imageDiffHtml; + mounted() { + if (this.isImageDiff) { + const canCreateNote = false; + const renderCommentBadge = true; + imageDiffHelper.initImageDiff(this.$refs.fileHolder, canCreateNote, renderCommentBadge); + } else if (!this.hasTruncatedDiffLines) { + this.fetchDiff(); + } }, - }, - 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); - }); - } - }, - methods: { - rowTag(html) { - return html.outerHTML ? 'tr' : 'template'; + 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> <template> @@ -63,23 +97,59 @@ export default { :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 - v-for="(html, index) in diffRows" - :is="rowTag(html)" - :class="html.className" - :key="index" - v-html="html.outerHTML" - /> + <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 68e17ac8055..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); } }, @@ -97,7 +102,7 @@ export default { <a v-tooltip :href="resolveAllDiscussionsIssuePath" - title="Resolve all discussions in new issue" + :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> @@ -112,7 +117,7 @@ export default { title="Jump to first unresolved discussion" data-container="body" class="btn btn-default discussion-next-btn" - @click="jumpToFirstDiscussion"> + @click="jumpToFirstUnresolvedDiscussion"> <span v-html="nextDiscussionSvg"></span> </button> </div> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 0bf4258a257..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,7 +134,7 @@ export default { {{ accessLevel }} </span> <div - v-if="resolvable" + v-if="canResolve" class="note-actions-item"> <button v-tooltip @@ -216,6 +225,15 @@ 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 class="btn btn-transparent js-note-delete js-note-delete" diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 521b4d16286..225d9f18612 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -200,6 +200,7 @@ export default { :class="getAwardClassBindings(awardList, awardName)" :title="awardTitle(awardList)" class="btn award-control" + data-boundary="viewport" data-placement="bottom" type="button" @click="handleAward(awardName)"> @@ -217,6 +218,7 @@ 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 diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 864edcd2ec6..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); }, }, }; @@ -93,7 +93,7 @@ export default { :note-body="noteBody" :note-id="note.id" @handleFormUpdate="handleFormUpdate" - @cancelFormEdition="formCancelHandler" + @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 7254ef3357d..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)" @@ -170,7 +165,7 @@ export default { :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" aria-label="Description" placeholder="Write a comment or drag your files here…" @@ -184,22 +179,22 @@ js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-save" + class="js-vue-issue-save btn btn-save js-comment-button " @click="handleUpdate()"> {{ saveButtonTitle }} </button> <button - v-if="note.resolvable" + v-if="discussion.resolvable" class="btn btn-nr btn-default append-right-10 js-comment-resolve-button" @click.prevent="handleUpdate(true)" > {{ resolveButtonTitle }} </button> <button - class="btn btn-cancel note-edit-cancel" + class="btn btn-cancel note-edit-cancel js-close-discussion-note-form" type="button" @click="cancelHandler()"> - Cancel + {{ __('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 ffe3ba9c805..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, @@ -88,10 +83,8 @@ export default { <template v-if="actionText"> {{ actionText }} </template> - <span - v-if="actionTextHtml" - class="system-note-message" - v-html="actionTextHtml"> + <span class="system-note-message"> + <slot></slot> </span> <span class="system-note-separator"> · diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 3f865431155..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 (!window.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" - action-text="started a discussion" - class="discussion" + :expanded="discussion.expanded" @toggleHandler="toggleDiscussionHandler" + > + <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,27 +344,28 @@ 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 type="button" - class="js-vue-discussion-reply btn btn-text-field" + 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 type="button" - class="btn btn-default" + class="btn btn-default mr-2" @click="resolveHandler()" > <i @@ -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" > @@ -302,17 +387,17 @@ Please check your network connection and try again.`; role="group"> <a v-tooltip - :href="note.resolve_with_issue_path" + :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 @@ -320,7 +405,7 @@ Please check your network connection and try again.`; class="btn btn-default discussion-next-btn" title="Jump to next unresolved discussion" data-container="body" - @click="jumpToDiscussion" + @click="jumpToNextDiscussion" > <span v-html="nextDiscussionsSvg"></span> </button> @@ -331,11 +416,11 @@ Please check your network connection and try again.`; <note-form v-if="isReplying" ref="noteForm" - :note="note" + :discussion="discussion" :is-editing="false" save-button-title="Comment" @handleFormUpdate="saveReply" - @cancelFormEdition="cancelReplyForm" /> + @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 9225a6b1a7c..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,13 +71,14 @@ 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; }, @@ -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 (!window.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; }, }, }; @@ -154,7 +157,9 @@ export default { :id="noteAnchorId" :class="classNameBindings" :data-award-url="note.toggle_award_path" - class="note timeline-entry"> + :data-note-id="note.id" + class="note timeline-entry" + > <div class="timeline-entry-inner"> <div class="timeline-icon"> <user-avatar-link @@ -174,11 +179,13 @@ export default { <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" @@ -195,7 +202,7 @@ export default { :can-edit="note.current_user.can_edit" :is-editing="isEditing" @handleFormUpdate="formUpdateHandler" - @cancelFormEdition="formCancelHandler" + @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 5b5b1e89058..2c3e07c0506 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -11,7 +11,7 @@ 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'; 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/getters.js b/app/assets/javascripts/notes/stores/getters.js index bc373e0d0fc..5c65e1c3bb5 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,60 +1,93 @@ import _ from 'underscore'; +import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; -export const notes = state => collapseSystemNotes(state.notes); +export const discussions = state => collapseSystemNotes(state.discussions); 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.filter(d => d.resolvable).forEach(n => { if (n.notes) { - const resolved = n.notes.every(note => note.resolved && !note.system); + const resolved = n.notes.filter(note => note.resolvable).every(note => note.resolved); if (resolved) { map[n.id] = n; @@ -71,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/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index cc2805a1901..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'; diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index c334eaa90f8..9aa83ce6269 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'; @@ -39,6 +39,7 @@ export default class Todos { } initFilters() { + this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']); this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']); this.initFilterDropdown($('.js-type-search'), 'type'); this.initFilterDropdown($('.js-action-search'), 'action_id'); @@ -53,7 +54,16 @@ export default class Todos { filterable: searchFields ? true : false, search: { fields: searchFields }, data: $dropdown.data('data'), - clicked: () => $dropdown.closest('form.filter-form').submit(), + clicked: () => { + const $formEl = $dropdown.closest('form.filter-form'); + const mutexDropdowns = { + group_id: 'project_id', + project_id: 'group_id', + }; + + $formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove(); + $formEl.submit(); + }, }); } @@ -61,7 +71,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/projects/clusters/gcp/login/index.js b/app/assets/javascripts/pages/projects/clusters/gcp/login/index.js deleted file mode 100644 index 0c2d7d7c96a..00000000000 --- a/app/assets/javascripts/pages/projects/clusters/gcp/login/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; - -gcpSignupOffer(); diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js deleted file mode 100644 index 0c2d7d7c96a..00000000000 --- a/app/assets/javascripts/pages/projects/clusters/new/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; - -gcpSignupOffer(); 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 37336a8cb69..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'; @@ -80,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); @@ -102,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 165446a4db6..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 { diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index de1e13de7e9..cc0e6553e83 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,7 +1,21 @@ +import gcpSignupOffer from '~/clusters/components/gcp_signup_offer'; +import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; import Project from './project'; import ShortcutsNavigation from '../../shortcuts_navigation'; document.addEventListener('DOMContentLoaded', () => { + const { page } = document.body.dataset; + const newClusterViews = [ + 'projects:clusters:new', + 'projects:clusters:create_gcp', + 'projects:clusters:create_user', + ]; + + if (newClusterViews.indexOf(page) > -1) { + gcpSignupOffer(); + initGkeDropdowns(); + } + new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new }); 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/jobs/terminal/index.js b/app/assets/javascripts/pages/projects/jobs/terminal/index.js new file mode 100644 index 00000000000..7129e24cee1 --- /dev/null +++ b/app/assets/javascripts/pages/projects/jobs/terminal/index.js @@ -0,0 +1,3 @@ +import initTerminal from '~/terminal/'; + +document.addEventListener('DOMContentLoaded', initTerminal); 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/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 37ef77c8e43..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 @@ -28,7 +28,7 @@ document.addEventListener('DOMContentLoaded', () => { const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); autoDevOpsSettings.addEventListener('click', event => { - const target = event.target; + const { target } = event; if (target.classList.contains('js-toggle-extra-settings')) { autoDevOpsExtraSettings.classList.toggle( 'hidden', 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/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/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 87213c94eda..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'; 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/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 1f152ed438d..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'); 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 e047d10ac93..c32dc83da8e 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -109,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 886e62ab1a7..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" > 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/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 5b212ee8931..001eaeaa065 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,81 +1,81 @@ <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"> diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue index 1fce9f16ee0..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"> diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index a107e579457..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"> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index b31b4bad7a0..c9d2dc3a3c5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -1,283 +1,283 @@ <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, + }, + canCreatePipeline: { + type: Boolean, + required: true, + }, + ciLintPath: { + type: String, + required: false, + default: null, }, - scopes: { - all: 'all', - pending: 'pending', - running: 'running', - finished: 'finished', - branches: 'branches', - tags: 'tags', + resetCachePath: { + type: String, + required: false, + default: null, }, - 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; + 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', + + // 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.isLoading) { - return stateMap.loading; - } + if (this.isLoading) { + return stateMap.loading; + } - if (this.hasError) { - return stateMap.error; - } + if (this.hasError) { + return stateMap.error; + } - if (this.state.pipelines.length) { - return stateMap.tableList; - } + if (this.state.pipelines.length) { + return stateMap.tableList; + } - if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) { - return stateMap.emptyTab; - } + 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, - ) - ); - }, + 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"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index 5070c253f11..1c8d7303c52 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -1,44 +1,44 @@ <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"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue index 490df47e154..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 diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 2e777783636..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"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index b2744a30c2a..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"> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue index b9231c002fd..56fdb858088 100644 --- a/app/assets/javascripts/pipelines/components/stage.vue +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -186,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 0a97df2dc18..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"> 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/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index ba120c4bbdf..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,14 +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 = []; - // eslint-disable-next-line no-multi-assign - 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 17497283695..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,12 +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 = []; - // eslint-disable-next-line no-multi-assign - 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; 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/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index c772fca14bb..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; 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/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/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 ef3c71eeafe..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(); } } 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_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/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js index 78f7353eb0d..6b595764bc5 100644 --- a/app/assets/javascripts/shortcuts_navigation.js +++ b/app/assets/javascripts/shortcuts_navigation.js @@ -20,6 +20,7 @@ export default class ShortcutsNavigation extends Shortcuts { Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets')); Mousetrap.bind('g k', () => findAndFollowLink('.shortcuts-kubernetes')); Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-environments')); + Mousetrap.bind('g l', () => findAndFollowLink('.shortcuts-metrics')); Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue')); this.enabledHelp.push('.hidden-shortcut.project'); diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue new file mode 100644 index 00000000000..ffaed9c7193 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -0,0 +1,98 @@ +<script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; + +const MARK_TEXT = __('Mark todo as done'); +const TODO_TEXT = __('Add todo'); + +export default { + directives: { + tooltip, + }, + components: { + Icon, + LoadingIcon, + }, + props: { + issuableId: { + type: Number, + required: true, + }, + issuableType: { + type: String, + required: true, + }, + isTodo: { + type: Boolean, + required: false, + default: true, + }, + isActionActive: { + type: Boolean, + required: false, + default: false, + }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + buttonClasses() { + return this.collapsed ? + 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state' : + 'btn btn-default btn-todo issuable-header-btn float-right'; + }, + buttonLabel() { + return this.isTodo ? MARK_TEXT : TODO_TEXT; + }, + collapsedButtonIconClasses() { + return this.isTodo ? 'todo-undone' : ''; + }, + collapsedButtonIcon() { + return this.isTodo ? 'todo-done' : 'todo-add'; + }, + }, + methods: { + handleButtonClick() { + this.$emit('toggleTodo'); + }, + }, +}; +</script> + +<template> + <button + v-tooltip + :class="buttonClasses" + :title="buttonLabel" + :aria-label="buttonLabel" + :data-issuable-id="issuableId" + :data-issuable-type="issuableType" + type="button" + data-container="body" + data-placement="left" + data-boundary="viewport" + @click="handleButtonClick" + > + <icon + v-show="collapsed" + :css-classes="collapsedButtonIconClasses" + :name="collapsedButtonIcon" + /> + <span + v-show="!collapsed" + class="issuable-todo-inner" + > + {{ buttonLabel }} + </span> + <loading-icon + v-show="isActionActive" + :inline="true" + /> + </button> +</template> 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/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index ae27c676fa0..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'; 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/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 349614460e1..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,8 +258,7 @@ function UsersSelect(currentUser, els, options = {}) { showDivider = 0; if (firstUser) { // Move current user to the front of the list - // eslint-disable-next-line no-multi-assign - 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); @@ -502,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) { @@ -554,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 }; @@ -563,8 +561,7 @@ function UsersSelect(currentUser, els, options = {}) { // Move current user to the front of the list ref = data.results; - // eslint-disable-next-line no-multi-assign - 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 c44419d24e6..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,11 +112,10 @@ 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 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/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 0d9a560c88e..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" /> @@ -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") }} 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/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index bc4ba3d050b..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 @@ -172,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() { @@ -191,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}"`; @@ -211,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) => { 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 6851029018a..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 @@ -42,7 +42,7 @@ export default { }, methods: { onImgLoad() { - const contentImg = this.$refs.contentImg; + const { contentImg } = this.$refs; if (contentImg) { this.isZoomable = 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/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index 2c47f5b9b35..d3cbe3c7e74 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -45,11 +45,15 @@ export 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 `${gon.relative_url_root}/${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`; + return `${this.basePath}${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`; }, fullNewPath() { - return `${gon.relative_url_root}/${this.projectPath}/raw/${this.newSha}/${this.newPath}`; + return `${this.basePath}${this.projectPath}/raw/${this.newSha}/${this.newPath}`; }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 05e8ed2da2c..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'); @@ -150,7 +158,7 @@ </div> <div v-show="previewMarkdown" - class="md md-preview-holder md-preview" + class="md md-preview-holder md-preview js-vue-md-preview" > <div ref="markdown-preview" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index ee3628b1e3f..8c22f3f6536 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -29,8 +29,8 @@ methods: { isValid(form) { return !form || - form.find('.js-vue-markdown-field').length || - $(this.$el).closest('form') === form[0]; + form.find('.js-vue-markdown-field').length && + $(this.$el).closest('form')[0] === form[0]; }, previewMarkdownTab(event, form) { @@ -71,7 +71,7 @@ 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)" 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/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index ac2e99abe77..80dc7d3557c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -12,6 +12,11 @@ export default { type: Boolean, required: true, }, + cssClasses: { + type: String, + required: false, + default: '', + }, }, computed: { tooltipLabel() { @@ -30,10 +35,12 @@ export default { <button v-tooltip :title="tooltipLabel" + :class="cssClasses" type="button" class="btn btn-blank gutter-toggle btn-sidebar-action" data-container="body" data-placement="left" + data-boundary="viewport" @click="toggle" > <i diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue index 2370e59d017..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) { 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 e6303ad4642..ded33e8b151 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -89,11 +89,6 @@ a { color: $gl-link-color; } -a:not(.btn):focus, -a:not(.btn):active { - text-decoration: underline; -} - hr { overflow: hidden; } @@ -133,6 +128,11 @@ table { border-spacing: 0; } +.tooltip { + // Fix bootstrap4 bug whereby tooltips flicker when they are hovered over their borders + pointer-events: none; +} + .popover { font-size: 14px; } @@ -178,6 +178,12 @@ table { display: none; } +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, @@ -309,7 +315,7 @@ pre code { color: $white-light; h4, - a, + a:not(.btn), .alert-link { color: $white-light; } 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 0de05548c68..340fddd398b 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -13,6 +13,7 @@ &.diff-collapsed { padding: 5px; + line-height: 34px; .click-to-expand { cursor: pointer; @@ -349,11 +350,6 @@ } } -.flex-container-block { - display: -webkit-flex; - display: flex; -} - .flex-right { margin-left: auto; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 326499125fc..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; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index cccd1a6d942..ea4cb9a0b75 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -68,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; @@ -77,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; } } @@ -193,7 +192,6 @@ &:focus { background: $link-active-background; color: $gl-text-color; - text-decoration: none; } } @@ -215,7 +213,7 @@ > li { > a { @include media-breakpoint-up(sm) { - margin-right: 2px; + margin-right: 1px; } &:hover { @@ -225,7 +223,7 @@ &.is-showing-fly-out { > a { - margin-right: 2px; + margin-right: 1px; } .sidebar-sub-level-items { @@ -318,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; @@ -380,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/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 f060254777c..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 { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index a6e324036ae..e4bcb92876d 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -42,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 2b2e6d69e33..282e424fc38 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -243,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/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index b40d02f381a..aaa8bed3df0 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -180,10 +180,6 @@ color: $border-and-box-shadow; } - .ide-file-list .file.file-active { - color: $border-and-box-shadow; - } - .ide-sidebar-link { &.active { color: $border-and-box-shadow; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index db59c91e375..8bcaf5eb6ac 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -268,8 +268,6 @@ .navbar-sub-nav, .navbar-nav { - align-items: center; - > li { > a:hover, > a:focus { @@ -527,7 +525,7 @@ .header-user { .dropdown-menu { width: auto; - min-width: 160px; + min-width: unset; margin-top: 4px; color: $gl-text-color; left: auto; @@ -539,6 +537,10 @@ display: block; } } + + svg { + vertical-align: text-top; + } } } @@ -558,7 +560,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/variables.scss b/app/assets/stylesheets/framework/variables.scss index f30f296d41f..7808f6d3a25 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -233,7 +233,7 @@ $md-area-border: #ddd; /* * Code */ -$code_font_size: 12px; +$code_font_size: 90%; $code_line_height: 1.6; /* 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/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 7c1d1626f1c..750d2c8b990 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -290,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/commits.scss b/app/assets/stylesheets/pages/commits.scss index 49226ae8eac..f75be4e01cd 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -261,12 +261,16 @@ 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/diff.scss b/app/assets/stylesheets/pages/diff.scss index fbc97ec0c95..7e89f8998fb 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: middle; } } @@ -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 { @@ -491,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; @@ -677,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 { @@ -721,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; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 06f08ae2215..199039f38f7 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -222,6 +222,23 @@ } } +.prometheus-graphs { + .environments { + .dropdown-menu-toggle { + svg { + position: absolute; + right: 5%; + top: 25%; + } + } + + .dropdown-menu-toggle, + .dropdown-menu { + width: 240px; + } + } +} + .environments-actions { .external-url, .monitoring-url, diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index f9fd9f1ab8b..f6617380cc0 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -449,6 +449,7 @@ .todo-undone { color: $gl-link-color; + fill: $gl-link-color; } .author { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 79cac7f4ff0..391dfea0703 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -79,6 +79,7 @@ justify-content: space-between; padding: $gl-padding; border-radius: $border-radius-default; + border: 1px solid $theme-gray-100; &.sortable-ghost { opacity: 0.3; @@ -89,6 +90,7 @@ cursor: move; cursor: -webkit-grab; cursor: -moz-grab; + border: 0; &:active { cursor: -webkit-grabbing; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 596d3aa171c..efd730af558 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -46,7 +46,6 @@ .btn { font-size: $gl-font-size; - max-height: 26px; &[disabled] { opacity: 0.3; @@ -600,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 { @@ -740,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 3849a04db5d..5e5696b1602 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -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/repo.scss b/app/assets/stylesheets/pages/repo.scss index 0a56153203c..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; } @@ -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 { @@ -587,11 +597,17 @@ &:hover, &:focus { - background: $white-normal; + background: $theme-gray-100; + } + + &:active { + background: $theme-gray-200; } } .multi-file-commit-list-path { + cursor: pointer; + &.is-active { background-color: $white-normal; } @@ -611,10 +627,6 @@ .multi-file-commit-list-file-path { @include str-truncated(calc(100% - 30px)); - &:hover { - text-decoration: underline; - } - &:active { text-decoration: none; } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 765c926751a..2d66f336076 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -114,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 2f28031b9c8..839ac5ba59b 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -191,6 +191,22 @@ } } +.initialize-with-readme-setting { + .form-check { + margin-bottom: 10px; + + .option-title { + font-weight: $gl-font-weight-normal; + display: inline-block; + color: $gl-text-color; + } + + .option-description { + color: $project-option-descr-color; + } + } +} + .prometheus-metrics-monitoring { .card { .card-toggle { @@ -255,25 +271,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; } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index e5d7dd13915..010a2c05a1c 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -174,6 +174,18 @@ } } +@include media-breakpoint-down(lg) { + .todos-filters { + .filter-categories { + width: 75%; + + .filter-item { + margin-bottom: 10px; + } + } + } +} + @include media-breakpoint-down(xs) { .todo { .avatar { @@ -199,6 +211,10 @@ } .todos-filters { + .filter-categories { + width: auto; + } + .dropdown-menu-toggle { width: 100%; } diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index 5127ddfde6e..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; @@ -82,7 +81,7 @@ .view { margin-right: 15px; - float: left; + flex-shrink: 0; &:last-child { margin-right: 0; 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 fb788c47ef1..6944857bd33 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -52,8 +52,7 @@ class Admin::HooksController < Admin::ApplicationController end def hook_logs - @hook_logs ||= - Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page]) + @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]) end def hook_params 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 56312f801fb..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? diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index d04eb192129..ba510968684 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -90,7 +90,7 @@ module IssuableActions end def discussions - notes = issuable.notes + notes = issuable.discussion_notes .inc_relations_for_view .includes(:noteable) .fresh 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/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 90bb7a87b45..7ac63c914fa 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -10,6 +10,7 @@ module PreviewMarkdown when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } when 'snippets' then { skip_project_check: true } when 'groups' then { group: group } + when 'projects' then { issuable_state_filter_enabled: true } else {} end diff --git a/app/controllers/concerns/todos_actions.rb b/app/controllers/concerns/todos_actions.rb new file mode 100644 index 00000000000..c0acdb3498d --- /dev/null +++ b/app/controllers/concerns/todos_actions.rb @@ -0,0 +1,12 @@ +module TodosActions + extend ActiveSupport::Concern + + def create + todo = TodoService.new.mark_todo(issuable, current_user) + + render json: { + count: TodosFinder.new(current_user, state: :pending).execute.count, + delete_path: dashboard_todo_path(todo) + } + end +end diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb index 16374146ae4..434459a225a 100644 --- a/app/controllers/concerns/uploads_actions.rb +++ b/app/controllers/concerns/uploads_actions.rb @@ -45,6 +45,16 @@ module UploadsActions send_upload(uploader, attachment: uploader.filename, disposition: disposition) end + def authorize + set_workhorse_internal_api_content_type + + authorized = uploader_class.workhorse_authorize( + has_length: false, + maximum_size: Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i) + + render json: authorized + end + private # Explicitly set the format. diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 4d4ac025f8c..ccfcbbdc776 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -7,7 +7,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController skip_cross_project_access_check :index, :starred def index - @projects = load_projects(params.merge(non_public: true)).page(params[:page]) + @projects = load_projects(params.merge(non_public: true)) respond_to do |format| format.html @@ -25,7 +25,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController def starred @projects = load_projects(params.merge(starred: true)) - .includes(:forked_from_project, :tags).page(params[:page]) + .includes(:forked_from_project, :tags) @groups = [] @@ -51,6 +51,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController .new(params: finder_params, current_user: current_user) .execute .includes(:route, :creator, namespace: [:route, :owner]) + .page(finder_params[:page]) prepare_projects_for_rendering(projects) end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index f9e8fe624e8..bd7111e28bc 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -70,7 +70,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController end def todo_params - params.permit(:action_id, :author_id, :project_id, :type, :sort, :state) + params.permit(:action_id, :author_id, :project_id, :type, :sort, :state, :group_id) end def redirect_out_of_range(todos) diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb index f1578f75e88..74760194a1f 100644 --- a/app/controllers/groups/uploads_controller.rb +++ b/app/controllers/groups/uploads_controller.rb @@ -1,9 +1,11 @@ class Groups::UploadsController < Groups::ApplicationController include UploadsActions + include WorkhorseRequest skip_before_action :group, if: -> { action_name == 'show' && image_or_video? } - before_action :authorize_upload_file!, only: [:create] + before_action :authorize_upload_file!, only: [:create, :authorize] + before_action :verify_workhorse_api!, only: [:authorize] private 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/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/blob_controller.rb b/app/controllers/projects/blob_controller.rb index a8c0a68fc17..ebc61264b39 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -3,8 +3,8 @@ 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] @@ -93,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? @@ -103,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) diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb deleted file mode 100644 index c2c5ad61e01..00000000000 --- a/app/controllers/projects/clusters/gcp_controller.rb +++ /dev/null @@ -1,76 +0,0 @@ -class Projects::Clusters::GcpController < Projects::ApplicationController - before_action :authorize_read_cluster! - before_action :authorize_create_cluster!, only: [:new, :create] - before_action :authorize_google_api, except: :login - helper_method :token_in_session - - def login - begin - state = generate_session_key_redirect(gcp_new_namespace_project_clusters_path.to_s) - - @authorize_url = GoogleApi::CloudPlatform::Client.new( - nil, callback_google_api_auth_url, - state: state).authorize_url - rescue GoogleApi::Auth::ConfigMissingError - # no-op - end - end - - def new - @cluster = ::Clusters::Cluster.new.tap do |cluster| - cluster.build_provider_gcp - end - end - - def create - @cluster = ::Clusters::CreateService - .new(project, current_user, create_params) - .execute(token_in_session) - - if @cluster.persisted? - redirect_to project_cluster_path(project, @cluster) - else - render :new - end - end - - private - - def create_params - params.require(:cluster).permit( - :enabled, - :name, - :environment_scope, - provider_gcp_attributes: [ - :gcp_project_id, - :zone, - :num_nodes, - :machine_type - ]).merge( - provider_type: :gcp, - platform_type: :kubernetes - ) - end - - def authorize_google_api - unless GoogleApi::CloudPlatform::Client.new(token_in_session, nil) - .validate_token(expires_at_in_session) - redirect_to action: 'login' - end - end - - def token_in_session - session[GoogleApi::CloudPlatform::Client.session_key_for_token] - end - - def expires_at_in_session - @expires_at_in_session ||= - session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] - end - - def generate_session_key_redirect(uri) - GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key| - session[key] = uri - end - end -end diff --git a/app/controllers/projects/clusters/user_controller.rb b/app/controllers/projects/clusters/user_controller.rb deleted file mode 100644 index d0db64b2fa9..00000000000 --- a/app/controllers/projects/clusters/user_controller.rb +++ /dev/null @@ -1,40 +0,0 @@ -class Projects::Clusters::UserController < Projects::ApplicationController - before_action :authorize_read_cluster! - before_action :authorize_create_cluster!, only: [:new, :create] - - def new - @cluster = ::Clusters::Cluster.new.tap do |cluster| - cluster.build_platform_kubernetes - end - end - - def create - @cluster = ::Clusters::CreateService - .new(project, current_user, create_params) - .execute - - if @cluster.persisted? - redirect_to project_cluster_path(project, @cluster) - else - render :new - end - end - - private - - def create_params - params.require(:cluster).permit( - :enabled, - :name, - :environment_scope, - platform_kubernetes_attributes: [ - :namespace, - :api_url, - :token, - :ca_cert - ]).merge( - provider_type: :user, - platform_type: :kubernetes - ) - end -end diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index d58039b7d42..62193257940 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -1,10 +1,15 @@ class Projects::ClustersController < Projects::ApplicationController - before_action :cluster, except: [:index, :new] + before_action :cluster, except: [:index, :new, :create_gcp, :create_user] before_action :authorize_read_cluster! + before_action :generate_gcp_authorize_url, only: [:new] + before_action :validate_gcp_token, only: [:new] + before_action :gcp_cluster, only: [:new] + before_action :user_cluster, only: [:new] before_action :authorize_create_cluster!, only: [:new] before_action :authorize_update_cluster!, only: [:update] before_action :authorize_admin_cluster!, only: [:destroy] before_action :update_applications_status, only: [:status] + helper_method :token_in_session STATUS_POLLING_INTERVAL = 10_000 @@ -64,6 +69,38 @@ class Projects::ClustersController < Projects::ApplicationController end end + def create_gcp + @gcp_cluster = ::Clusters::CreateService + .new(project, current_user, create_gcp_cluster_params) + .execute(token_in_session) + + if @gcp_cluster.persisted? + redirect_to project_cluster_path(project, @gcp_cluster) + else + generate_gcp_authorize_url + validate_gcp_token + user_cluster + + render :new, locals: { active_tab: 'gcp' } + end + end + + def create_user + @user_cluster = ::Clusters::CreateService + .new(project, current_user, create_user_cluster_params) + .execute(token_in_session) + + if @user_cluster.persisted? + redirect_to project_cluster_path(project, @user_cluster) + else + generate_gcp_authorize_url + validate_gcp_token + gcp_cluster + + render :new, locals: { active_tab: 'user' } + end + end + private def cluster @@ -95,6 +132,80 @@ class Projects::ClustersController < Projects::ApplicationController end end + def create_gcp_cluster_params + params.require(:cluster).permit( + :enabled, + :name, + :environment_scope, + provider_gcp_attributes: [ + :gcp_project_id, + :zone, + :num_nodes, + :machine_type + ]).merge( + provider_type: :gcp, + platform_type: :kubernetes + ) + end + + def create_user_cluster_params + params.require(:cluster).permit( + :enabled, + :name, + :environment_scope, + platform_kubernetes_attributes: [ + :namespace, + :api_url, + :token, + :ca_cert + ]).merge( + provider_type: :user, + platform_type: :kubernetes + ) + end + + def generate_gcp_authorize_url + state = generate_session_key_redirect(new_project_cluster_path(@project).to_s) + + @authorize_url = GoogleApi::CloudPlatform::Client.new( + nil, callback_google_api_auth_url, + state: state).authorize_url + rescue GoogleApi::Auth::ConfigMissingError + # no-op + end + + def gcp_cluster + @gcp_cluster = ::Clusters::Cluster.new.tap do |cluster| + cluster.build_provider_gcp + end + end + + def user_cluster + @user_cluster = ::Clusters::Cluster.new.tap do |cluster| + cluster.build_platform_kubernetes + end + end + + def validate_gcp_token + @valid_gcp_token = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + .validate_token(expires_at_in_session) + end + + def token_in_session + session[GoogleApi::CloudPlatform::Client.session_key_for_token] + end + + def expires_at_in_session + @expires_at_in_session ||= + session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] + end + + def generate_session_key_redirect(uri) + GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key| + session[key] = uri + end + end + def authorize_update_cluster! access_denied! unless can?(current_user, :update_cluster, cluster) 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/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 0821362f5df..27b7425b965 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -120,6 +120,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + def metrics_redirect + environment = project.default_environment + + if environment + redirect_to environment_metrics_path(environment) + else + render :empty + end + end + def metrics # Currently, this acts as a hint to load the metrics details into the cache # if they aren't there already diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index dd7aa1a67b9..6800d742b0a 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -58,8 +58,7 @@ class Projects::HooksController < Projects::ApplicationController end def hook_logs - @hook_logs ||= - Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page]) + @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]) end def hook_params diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index dd12d30a085..e69faae754a 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -2,11 +2,12 @@ class Projects::JobsController < Projects::ApplicationController include SendFileUpload before_action :build, except: [:index, :cancel_all] - before_action :authorize_read_build!, - only: [:index, :show, :status, :raw, :trace] + before_action :authorize_read_build! before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace, :cancel_all, :erase] before_action :authorize_erase_build!, only: [:erase] + before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_workhorse_authorize] + before_action :verify_api_request!, only: :terminal_websocket_authorize layout 'project' @@ -44,12 +45,10 @@ class Projects::JobsController < Projects::ApplicationController end def show - @builds = @project.pipelines - .find_by_sha(@build.sha) - .builds + @pipeline = @build.pipeline + @builds = @pipeline.builds .order('id DESC') .present(current_user: current_user) - @pipeline = @build.pipeline respond_to do |format| format.html @@ -136,6 +135,15 @@ class Projects::JobsController < Projects::ApplicationController end end + def terminal + end + + # GET .../terminal.ws : implemented in gitlab-workhorse + def terminal_websocket_authorize + set_workhorse_internal_api_content_type + render json: Gitlab::Workhorse.terminal_websocket(@build.terminal_specification) + end + private def authorize_update_build! @@ -146,6 +154,14 @@ class Projects::JobsController < Projects::ApplicationController return access_denied! unless can?(current_user, :erase_build, build) end + def authorize_use_build_terminal! + return access_denied! unless can?(current_user, :create_build_terminal, build) + end + + def verify_api_request! + Gitlab::Workhorse.verify_api_request!(request.headers) + end + def raw_send_params { type: 'text/plain; charset=utf-8', disposition: 'inline' } end @@ -160,7 +176,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/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 38918b3cd52..a7c5f858c42 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -42,6 +42,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @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) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index f85dcfe6bfc..594563d1f6f 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -77,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) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 768595ceeb4..45cef123c34 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -13,7 +13,7 @@ class Projects::PipelinesController < Projects::ApplicationController def index @scope = params[:scope] @pipelines = PipelinesFinder - .new(project, scope: @scope) + .new(project, current_user, scope: @scope) .execute .page(params[:page]) .per(30) @@ -178,7 +178,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def limited_pipelines_count(project, scope = nil) - finder = PipelinesFinder.new(project, scope: scope) + finder = PipelinesFinder.new(project, current_user, scope: scope) view_context.limited_counter_with_delimiter(finder.execute) end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index fb3f6eec2bd..322ec096ffb 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -74,7 +74,7 @@ module Projects .ordered .page(params[:page]).per(20) - @shared_runners = ::Ci::Runner.shared.active + @shared_runners = ::Ci::Runner.instance_type.active @shared_runners_count = @shared_runners.count(:all) diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb index a41fcb85c40..93fb9da6510 100644 --- a/app/controllers/projects/todos_controller.rb +++ b/app/controllers/projects/todos_controller.rb @@ -1,19 +1,13 @@ class Projects::TodosController < Projects::ApplicationController - before_action :authenticate_user!, only: [:create] - - def create - todo = TodoService.new.mark_todo(issuable, current_user) + include Gitlab::Utils::StrongMemoize + include TodosActions - render json: { - count: TodosFinder.new(current_user, state: :pending).execute.count, - delete_path: dashboard_todo_path(todo) - } - end + before_action :authenticate_user!, only: [:create] private def issuable - @issuable ||= begin + strong_memoize(:issuable) do case params[:issuable_type] when "issue" IssuesFinder.new(current_user, project_id: @project.id).find(params[:issuable_id]) diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index f5cf089ad98..7a85046164c 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -1,11 +1,13 @@ class Projects::UploadsController < Projects::ApplicationController include UploadsActions + include WorkhorseRequest # These will kick you out if you don't have access. skip_before_action :project, :repository, if: -> { action_name == 'show' && image_or_video? } - before_action :authorize_upload_file!, only: [:create] + before_action :authorize_upload_file!, only: [:create, :authorize] + before_action :verify_workhorse_api!, only: [:authorize] private 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 efb30ba4715..ec3a5788ba1 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 @@ -347,6 +347,7 @@ class ProjectsController < Projects::ApplicationController :visibility_level, :template_name, :merge_method, + :initialize_with_readme, project_feature_attributes: %i[ builds_access_level diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1a339f76d26..1de6ae24622 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,43 @@ 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! + + if verify_recaptcha + increment_successful_login_captcha_counter + else + increment_failed_login_captcha_counter + + 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 increment_failed_login_captcha_counter + Gitlab::Metrics.counter( + :failed_login_captcha_total, + 'Number of failed CAPTCHA attempts for logins'.freeze + ).increment + end + + def increment_successful_login_captcha_counter + Gitlab::Metrics.counter( + :successful_login_captcha_total, + 'Number of successful CAPTCHA attempts for logins'.freeze + ).increment + end + def log_failed_login Gitlab::AppLogger.info("Failed Login: username=#{user_params[:login]} ip=#{request.remote_ip}") end @@ -152,6 +195,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/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 5d5f72c4d86..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 @@ -311,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 diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 8d84ed4bdfb..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 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/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 0a487839aff..a99a889a7e9 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -1,15 +1,20 @@ class PipelinesFinder - attr_reader :project, :pipelines, :params + attr_reader :project, :pipelines, :params, :current_user ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze - def initialize(project, params = {}) + def initialize(project, current_user, params = {}) @project = project + @current_user = current_user @pipelines = project.pipelines @params = params end def execute + unless Ability.allowed?(current_user, :read_pipeline, project) + return Ci::Pipeline.none + end + items = pipelines items = by_scope(items) items = by_status(items) diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 09e2c586f2a..2156413fb26 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -15,6 +15,7 @@ class TodosFinder prepend FinderWithCrossProjectAccess include FinderMethods + include Gitlab::Utils::StrongMemoize requires_cross_project_access unless: -> { project? } @@ -34,9 +35,11 @@ class TodosFinder items = by_author(items) items = by_state(items) items = by_type(items) + items = by_group(items) # Filtering by project HAS TO be the last because we use # the project IDs yielded by the todos query thus far items = by_project(items) + items = visible_to_user(items) sort(items) end @@ -82,6 +85,10 @@ class TodosFinder params[:project_id].present? end + def group? + params[:group_id].present? + end + def project return @project if defined?(@project) @@ -100,18 +107,14 @@ class TodosFinder @project end - def project_ids(items) - ids = items.except(:order).select(:project_id) - if Gitlab::Database.mysql? - # To make UPDATE work on MySQL, wrap it in a SELECT with an alias - ids = Todo.except(:order).select('*').from("(#{ids.to_sql}) AS t") + def group + strong_memoize(:group) do + Group.find(params[:group_id]) end - - ids end def type? - type.present? && %w(Issue MergeRequest).include?(type) + type.present? && %w(Issue MergeRequest Epic).include?(type) end def type @@ -148,12 +151,37 @@ class TodosFinder def by_project(items) if project? - items.where(project: project) - else - projects = Project.public_or_visible_to_user(current_user) + items = items.where(project: project) + end + + items + end - items.joins(:project).merge(projects) + def by_group(items) + if group? + groups = group.self_and_descendants + items = items.where( + 'project_id IN (?) OR group_id IN (?)', + Project.where(group: groups).select(:id), + groups.select(:id) + ) end + + items + end + + def visible_to_user(items) + projects = Project.public_or_visible_to_user(current_user) + groups = Group.public_or_visible_to_user(current_user) + + items + .joins('LEFT JOIN namespaces ON namespaces.id = todos.group_id') + .joins('LEFT JOIN projects ON projects.id = todos.project_id') + .where( + 'project_id IN (?) OR group_id IN (?)', + projects.select(:id), + groups.select(:id) + ) end def by_state(items) 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/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index de4fc1d8e32..d9f9129d08a 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -2,7 +2,10 @@ class GitlabSchema < GraphQL::Schema use BatchLoader::GraphQL use Gitlab::Graphql::Authorize use Gitlab::Graphql::Present + use Gitlab::Graphql::Connections query(Types::QueryType) + + default_max_page_size 100 # mutation(Types::MutationType) end diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb new file mode 100644 index 00000000000..9ec45378d8e --- /dev/null +++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb @@ -0,0 +1,23 @@ +module ResolvesPipelines + extend ActiveSupport::Concern + + included do + type [Types::Ci::PipelineType], null: false + argument :status, + Types::Ci::PipelineStatusEnum, + required: false, + description: "Filter pipelines by their status" + argument :ref, + GraphQL::STRING_TYPE, + required: false, + description: "Filter pipelines by the ref they are run for" + argument :sha, + GraphQL::STRING_TYPE, + required: false, + description: "Filter pipelines by the sha of the commit they are run for" + end + + def resolve_pipelines(project, params = {}) + PipelinesFinder.new(project, context[:current_user], params).execute + end +end diff --git a/app/graphql/resolvers/merge_request_pipelines_resolver.rb b/app/graphql/resolvers/merge_request_pipelines_resolver.rb new file mode 100644 index 00000000000..00b51ee1381 --- /dev/null +++ b/app/graphql/resolvers/merge_request_pipelines_resolver.rb @@ -0,0 +1,16 @@ +module Resolvers + class MergeRequestPipelinesResolver < BaseResolver + include ::ResolvesPipelines + + alias_method :merge_request, :object + + def resolve(**args) + resolve_pipelines(project, args) + .merge(merge_request.all_pipelines) + end + + def project + merge_request.source_project + end + end +end diff --git a/app/graphql/resolvers/project_pipelines_resolver.rb b/app/graphql/resolvers/project_pipelines_resolver.rb new file mode 100644 index 00000000000..7f175a3b26c --- /dev/null +++ b/app/graphql/resolvers/project_pipelines_resolver.rb @@ -0,0 +1,11 @@ +module Resolvers + class ProjectPipelinesResolver < BaseResolver + include ResolvesPipelines + + alias_method :project, :object + + def resolve(**args) + resolve_pipelines(project, args) + end + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb index e033ef96ce9..754adf4c04d 100644 --- a/app/graphql/types/base_object.rb +++ b/app/graphql/types/base_object.rb @@ -1,6 +1,7 @@ module Types class BaseObject < GraphQL::Schema::Object prepend Gitlab::Graphql::Present + prepend Gitlab::Graphql::ExposePermissions field_class Types::BaseField end diff --git a/app/graphql/types/ci/pipeline_status_enum.rb b/app/graphql/types/ci/pipeline_status_enum.rb new file mode 100644 index 00000000000..2c12e5001d8 --- /dev/null +++ b/app/graphql/types/ci/pipeline_status_enum.rb @@ -0,0 +1,9 @@ +module Types + module Ci + class PipelineStatusEnum < BaseEnum + ::Ci::Pipeline.all_state_names.each do |state_symbol| + value state_symbol.to_s.upcase, value: state_symbol.to_s + end + end + end +end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb new file mode 100644 index 00000000000..bbb7d9354d0 --- /dev/null +++ b/app/graphql/types/ci/pipeline_type.rb @@ -0,0 +1,31 @@ +module Types + module Ci + class PipelineType < BaseObject + expose_permissions Types::PermissionTypes::Ci::Pipeline + + graphql_name 'Pipeline' + + field :id, GraphQL::ID_TYPE, null: false + field :iid, GraphQL::ID_TYPE, null: false + + field :sha, GraphQL::STRING_TYPE, null: false + field :before_sha, GraphQL::STRING_TYPE, null: true + field :status, PipelineStatusEnum, null: false + field :duration, + GraphQL::INT_TYPE, + null: true, + description: "Duration of the pipeline in seconds" + field :coverage, + GraphQL::FLOAT_TYPE, + null: true, + description: "Coverage percentage" + field :created_at, Types::TimeType, null: false + field :updated_at, Types::TimeType, null: false + field :started_at, Types::TimeType, null: true + field :finished_at, Types::TimeType, null: true + field :committed_at, Types::TimeType, null: true + + # TODO: Add triggering user as a type + end + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index d5d24952984..88cd2adc6dc 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -1,5 +1,7 @@ module Types class MergeRequestType < BaseObject + expose_permissions Types::PermissionTypes::MergeRequest + present_using MergeRequestPresenter graphql_name 'MergeRequest' @@ -43,5 +45,11 @@ module Types field :upvotes, GraphQL::INT_TYPE, null: false field :downvotes, GraphQL::INT_TYPE, null: false field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false + + field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do + authorize :read_pipeline + end + field :pipelines, Types::Ci::PipelineType.connection_type, + resolver: Resolvers::MergeRequestPipelinesResolver 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/ci/pipeline.rb b/app/graphql/types/permission_types/ci/pipeline.rb new file mode 100644 index 00000000000..942539c7cf7 --- /dev/null +++ b/app/graphql/types/permission_types/ci/pipeline.rb @@ -0,0 +1,11 @@ +module Types + module PermissionTypes + module Ci + class Pipeline < BasePermissionType + graphql_name 'PipelinePermissions' + + abilities :update_pipeline, :admin_pipeline, :destroy_pipeline + end + 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 index d9058ae7431..97707215b4e 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -1,5 +1,7 @@ module Types class ProjectType < BaseObject + expose_permissions Types::PermissionTypes::Project + graphql_name 'Project' field :id, GraphQL::ID_TYPE, null: false @@ -68,5 +70,10 @@ module Types resolver: Resolvers::MergeRequestResolver do authorize :read_merge_request end + + field :pipelines, + Types::Ci::PipelineType.connection_type, + null: false, + resolver: Resolvers::ProjectPipelinesResolver 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/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 5fce97164ae..f49b5c7b51a 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -122,7 +122,7 @@ module CiStatusHelper def no_runners_for_project?(project) project.runners.blank? && - Ci::Runner.shared.blank? + Ci::Runner.instance_type.blank? end def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body') diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 95fea2f18d1..3c5c8bbd71b 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -128,8 +128,10 @@ module GroupsHelper def get_group_sidebar_links links = [:overview, :group_members] - if can?(current_user, :read_cross_project) - links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests] + resources = [:activity, :issues, :boards, :labels, :milestones, + :merge_requests] + links += resources.select do |resource| + can?(current_user, "read_group_#{resource}".to_sym, @group) end if can?(current_user, :admin_group, @group) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 9f501ea55fb..353479776b8 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -131,6 +131,19 @@ module IssuablesHelper end end + def group_dropdown_label(group_id, default_label) + return default_label if group_id.nil? + return "Any group" if group_id == "0" + + group = ::Group.find_by(id: group_id) + + if group + group.full_name + else + default_label + end + end + def milestone_dropdown_label(milestone_title, default_label = "Milestone") title = case milestone_title diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 5ff06b3e0fc..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 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/projects_helper.rb b/app/helpers/projects_helper.rb index c405e6d117f..b0f381db5ab 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) @@ -176,6 +177,7 @@ module ProjectsHelper controller.action_name, Gitlab::CurrentSettings.cache_key, "cross-project:#{can?(current_user, :read_cross_project)}", + max_project_member_access_cache_key(project), 'v2.6' ] @@ -350,11 +352,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 @@ -501,4 +507,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/todos_helper.rb b/app/helpers/todos_helper.rb index f7620e0b6b8..7cd74358168 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -43,7 +43,7 @@ module TodosHelper project_commit_path(todo.project, todo.target, anchor: anchor) else - path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target] + path = [todo.parent, todo.target] path.unshift(:pipelines) if todo.build_failed? @@ -167,4 +167,12 @@ module TodosHelper def show_todo_state?(todo) (todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state) end + + def todo_group_options + groups = current_user.authorized_groups.map do |group| + { id: group.id, text: group.full_name } + end + + groups.unshift({ id: '', text: 'Any Group' }).to_json + end end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index ce9373f5883..4d17b22a4a1 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -31,6 +31,14 @@ module UsersHelper current_user_menu_items.include?(item) end + def max_project_member_access(project) + current_user&.max_member_access_for_project(project.id) || Gitlab::Access::NO_ACCESS + end + + def max_project_member_access_cache_key(project) + "access:#{max_project_member_access(project)}" + end + private def get_profile_tabs 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/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/board.rb b/app/models/board.rb index 3cede6fc99a..bb6bb753daf 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -26,4 +26,8 @@ class Board < ActiveRecord::Base def closed_list lists.merge(List.closed).take end + + def scoped? + false + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 41446946a5e..19949f83351 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -27,7 +27,13 @@ module Ci has_one :job_artifacts_trace, -> { where(file_type: Ci::JobArtifact.file_types[:trace]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id has_one :metadata, class_name: 'Ci::BuildMetadata' + has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build + + accepts_nested_attributes_for :runner_session + delegate :timeout, to: :metadata, prefix: true, allow_nil: true + delegate :url, to: :runner_session, prefix: true, allow_nil: true + delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project ## @@ -174,6 +180,10 @@ module Ci after_transition pending: :running do |build| build.ensure_metadata.update_timeout_state end + + after_transition running: any do |build| + Ci::BuildRunnerSession.where(build: build).delete_all + end end def ensure_metadata @@ -376,6 +386,10 @@ module Ci trace.exist? end + def has_old_trace? + old_trace.present? + end + def trace=(data) raise NotImplementedError end @@ -385,6 +399,8 @@ module Ci end def erase_old_trace! + return unless has_old_trace? + update_column(:trace, nil) end @@ -584,6 +600,10 @@ module Ci super(options).merge(when: read_attribute(:when)) end + def has_terminal? + running? && runner_session_url.present? + end + private def update_artifacts_size diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb new file mode 100644 index 00000000000..6f3be31d8e1 --- /dev/null +++ b/app/models/ci/build_runner_session.rb @@ -0,0 +1,25 @@ +module Ci + # The purpose of this class is to store Build related runner session. + # Data will be removed after transitioning from running to any state. + class BuildRunnerSession < ActiveRecord::Base + extend Gitlab::Ci::Model + + self.table_name = 'ci_builds_runner_session' + + belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session + + validates :build, presence: true + validates :url, url: { protocols: %w(https) } + + def terminal_specification + return {} unless url.present? + + { + subprotocols: ['terminal.gitlab.com'].freeze, + url: "#{url}/exec".sub("https://", "wss://"), + headers: { Authorization: authorization.presence }.compact, + ca_pem: certificate.presence + } + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f430f18ca9a..e5caa3ffa41 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -561,9 +561,9 @@ module Ci .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 diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 8c9aacca8de..bcd0c206bca 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -2,6 +2,7 @@ module Ci class Runner < ActiveRecord::Base extend Gitlab::Ci::Model include Gitlab::SQL::Pattern + include IgnorableColumn include RedisCacheable include ChronicDurationAttribute @@ -11,6 +12,8 @@ module Ci AVAILABLE_SCOPES = %w[specific shared active paused online].freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze + ignore_column :is_shared + has_many :builds has_many :runner_projects, inverse_of: :runner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects @@ -21,13 +24,16 @@ module Ci before_validation :set_default_values - scope :specific, -> { where(is_shared: false) } - scope :shared, -> { where(is_shared: true) } scope :active, -> { where(active: true) } scope :paused, -> { where(active: false) } scope :online, -> { where('contacted_at > ?', contact_time_deadline) } scope :ordered, -> { order(id: :desc) } + # BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb` + scope :deprecated_shared, -> { instance_type } + # this should get replaced with `project_type.or(group_type)` once using Rails5 + scope :deprecated_specific, -> { where(runner_type: [runner_types[:project_type], runner_types[:group_type]]) } + scope :belonging_to_project, -> (project_id) { joins(:runner_projects).where(ci_runner_projects: { project_id: project_id }) } @@ -39,9 +45,9 @@ module Ci joins(:groups).where(namespaces: { id: hierarchy_groups }) } - scope :owned_or_shared, -> (project_id) do + scope :owned_or_instance_wide, -> (project_id) do union = Gitlab::SQL::Union.new( - [belonging_to_project(project_id), belonging_to_parent_group_of_project(project_id), shared], + [belonging_to_project(project_id), belonging_to_parent_group_of_project(project_id), instance_type], remove_duplicates: false ) from("(#{union.to_sql}) ci_runners") @@ -63,7 +69,6 @@ module Ci validate :no_groups, unless: :group_type? validate :any_project, if: :project_type? validate :exactly_one_group, if: :group_type? - validate :validate_is_shared acts_as_taggable @@ -113,8 +118,7 @@ module Ci end def assign_to(project, current_user = nil) - if shared? - self.is_shared = false if shared? + if instance_type? self.runner_type = :project_type elsif group_type? raise ArgumentError, 'Transitioning a group runner to a project runner is not supported' @@ -137,10 +141,6 @@ module Ci description end - def shared? - is_shared - end - def online? contacted_at && contacted_at > self.class.contact_time_deadline end @@ -159,10 +159,6 @@ module Ci runner_projects.count == 1 end - def specific? - !shared? - end - def assigned_to_group? runner_namespaces.any? end @@ -260,7 +256,7 @@ module Ci end def assignable_for?(project_id) - self.class.owned_or_shared(project_id).where(id: self.id).any? + self.class.owned_or_instance_wide(project_id).where(id: self.id).any? end def no_projects @@ -287,12 +283,6 @@ module Ci end end - def validate_is_shared - unless is_shared? == instance_type? - errors.add(:is_shared, 'is not equal to instance_type?') - end - end - def accepting_tags?(build) (run_untagged? || build.has_tags?) && (build.tag_list - tag_list).empty? end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 44150b37708..7a459078151 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 @@ -239,6 +243,12 @@ module Issuable opened? end + def overdue? + return false unless respond_to?(:due_date) + + due_date.try(:past?) || false + end + def user_notes_count if notes.loaded? # Use the in-memory association to select and count to avoid hitting the db 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/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 9c171de7fc3..b0392774379 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -39,6 +39,8 @@ class Group < Namespace has_many :boards has_many :badges, class_name: 'GroupBadge' + has_many :todos + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects @@ -82,6 +84,12 @@ class Group < Namespace where(id: user.authorized_groups.select(:id).reorder(nil)) end + def public_or_visible_to_user(user) + where('id IN (?) OR namespaces.visibility_level IN (?)', + user.authorized_groups.select(:id), + Gitlab::VisibilityLevel.levels_for_user(user)) + end + def select_for_project_authorization if current_scope.joins_values.include?(:shared_projects) joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id') diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index e72c125fb69..59a1f2aed69 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -7,6 +7,11 @@ class WebHookLog < ActiveRecord::Base validates :web_hook, presence: true + def self.recent + where('created_at >= ?', 2.days.ago.beginning_of_day) + .order(created_at: :desc) + end + def success? response_status =~ /^2/ end diff --git a/app/models/issue.rb b/app/models/issue.rb index d136700836d..983684a5e05 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -48,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) } @@ -56,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) } @@ -275,10 +275,6 @@ class Issue < ActiveRecord::Base user ? readable_by?(user) : publicly_visible? end - def overdue? - due_date.try(:past?) || false - end - def check_for_spam? project.public? && (title_changed? || description_changed?) end @@ -308,6 +304,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 1cf04976602..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 diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 324065c1162..b4090fd8baf 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -128,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) @@ -369,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. @@ -610,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 @@ -706,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 @@ -1115,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 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/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 b407d3c18ad..bbad9d90cc4 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -229,6 +229,10 @@ class Note < ActiveRecord::Base !for_personal_snippet? end + def for_issuable? + for_issue? || for_merge_request? + end + def skip_project_check? !for_project_noteable? end @@ -384,6 +388,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 diff --git a/app/models/project.rb b/app/models/project.rb index 0d777515536..8f40470de82 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1422,7 +1422,7 @@ class Project < ActiveRecord::Base end def shared_runners - @shared_runners ||= shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none + @shared_runners ||= shared_runners_available? ? Ci::Runner.instance_type : Ci::Runner.none end def group_runners @@ -1774,6 +1774,15 @@ class Project < ActiveRecord::Base end end + def default_environment + production_first = "(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC" + + environments + .with_state(:available) + .reorder(production_first) + .first + end + def secret_variables_for(ref:, environment: nil) # EE would use the environment if protected_for?(ref) @@ -2019,6 +2028,10 @@ class Project < ActiveRecord::Base end request_cache(:any_lfs_file_locks?) { self.id } + def auto_cancel_pending_pipelines? + auto_cancel_pending_pipelines == 'enabled' + end + private def storage diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index d7d6aaceb27..faa831b1949 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -29,8 +29,8 @@ class ProjectAutoDevops < ActiveRecord::Base end if manual? - variables.append(key: 'STAGING_ENABLED', value: 1) - variables.append(key: 'INCREMENTAL_ROLLOUT_ENABLED', value: 1) + variables.append(key: 'STAGING_ENABLED', value: '1') + variables.append(key: 'INCREMENTAL_ROLLOUT_ENABLED', value: '1') end 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/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index ddd4026019b..722642f6da7 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -240,7 +240,7 @@ class KubernetesService < DeploymentService end def deprecation_validation - return if active_changed?(from: true, to: false) + return if active_changed?(from: true, to: false) || (new_record? && !active?) if deprecated? errors[:base] << deprecation_message 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/repository.rb b/app/models/repository.rb index c2f62badbcb..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 @@ -283,6 +283,10 @@ class Repository ) end + def cached_methods + CACHED_METHODS + end + def expire_tags_cache expire_method_caches(%i(tag_names tag_count)) @tags = nil @@ -423,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. @@ -850,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 diff --git a/app/models/service.rb b/app/models/service.rb index 1d259bcfec7..ad835293b46 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -281,9 +281,9 @@ class Service < ActiveRecord::Base def self.build_from_template(project_id, template) service = template.dup - service.active = false unless service.valid? service.template = false service.project_id = project_id + service.active = false if service.active? && !service.valid? service end diff --git a/app/models/todo.rb b/app/models/todo.rb index a2ab405fdbe..942cbb754e3 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -22,15 +22,18 @@ class Todo < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :note belongs_to :project + belongs_to :group belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user delegate :name, :email, to: :author, prefix: true, allow_nil: true - validates :action, :project, :target_type, :user, presence: true + validates :action, :target_type, :user, presence: true validates :author, presence: true validates :target_id, presence: true, unless: :for_commit? validates :commit_id, presence: true, if: :for_commit? + validates :project, presence: true, unless: :group_id + validates :group, presence: true, unless: :project_id scope :pending, -> { with_state(:pending) } scope :done, -> { with_state(:done) } @@ -44,7 +47,7 @@ class Todo < ActiveRecord::Base state :done end - after_save :keep_around_commit + after_save :keep_around_commit, if: :commit_id class << self # Priority sorting isn't displayed in the dropdown, because we don't show @@ -79,6 +82,10 @@ class Todo < ActiveRecord::Base end end + def parent + project + end + def unmergeable? action == UNMERGEABLE end diff --git a/app/models/user.rb b/app/models/user.rb index 8e0dc91b2a7..27a5d0278b7 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')) } @@ -1032,7 +1032,7 @@ class User < ActiveRecord::Base union = Gitlab::SQL::Union.new([project_runner_ids, group_runner_ids]) - Ci::Runner.specific.where("ci_runners.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection + Ci::Runner.where("ci_runners.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection end end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 1c0cc7425ec..75c7e529902 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -18,6 +18,10 @@ module Ci @subject.project.branch_allows_collaboration?(@user, @subject.ref) end + condition(:terminal, scope: :subject) do + @subject.has_terminal? + end + rule { protected_ref }.policy do prevent :update_build prevent :erase_build @@ -29,5 +33,7 @@ module Ci enable :update_build enable :update_commit_status end + + rule { can?(:update_build) & terminal }.enable :create_build_terminal end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 520710b757d..ded9fe30eff 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -72,6 +72,19 @@ class GroupPolicy < BasePolicy enable :change_visibility_level end + rule { can?(:read_nested_project_resources) }.policy do + enable :read_group_activity + enable :read_group_issues + enable :read_group_boards + enable :read_group_labels + enable :read_group_milestones + enable :read_group_merge_requests + end + + rule { can?(:read_cross_project) & can?(:read_group) }.policy do + enable :read_nested_project_resources + end + rule { owner & nested_groups_supported }.enable :create_subgroup rule { public_group | logged_in_viewable }.enable :view_globally diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 8d466c33510..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,10 @@ 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 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..61135fba97b 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -1,25 +1,48 @@ 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] + + next unless diff_file.blob + + 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 +51,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 +91,67 @@ 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 + next unless diff_file.content_sha + + 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| + next unless diff_file.content_sha + + 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..8a39a4950f5 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? && !d.legacy_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_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 8260c6c7b84..5d72ebdd7fd 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -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/runner_entity.rb b/app/serializers/runner_entity.rb index e9999a36d8a..db26eadab2d 100644 --- a/app/serializers/runner_entity.rb +++ b/app/serializers/runner_entity.rb @@ -4,7 +4,7 @@ class RunnerEntity < Grape::Entity expose :id, :description expose :edit_path, - if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner| + if: -> (*) { can?(request.current_user, :admin_build, project) && runner.project_type? } do |runner| edit_project_runner_path(project, runner) 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/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 9bdbb2c0d99..6eb1c4f52de 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -13,9 +13,9 @@ module Ci @runner = runner end - def execute + def execute(params = {}) builds = - if runner.shared? + if runner.instance_type? builds_for_shared_runner elsif runner.group_type? builds_for_group_runner @@ -41,6 +41,8 @@ module Ci # with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method. begin build.runner_id = runner.id + build.runner_session_attributes = params[:session] if params[:session].present? + build.run! register_success(build) @@ -99,7 +101,7 @@ module Ci end def running_builds_for_shared_runners - Ci::Build.running.where(runner: Ci::Runner.shared) + Ci::Build.running.where(runner: Ci::Runner.instance_type) .group(:project_id).select(:project_id, 'count(*) AS running_builds') end @@ -115,7 +117,7 @@ module Ci end def register_success(job) - labels = { shared_runner: runner.shared?, + labels = { shared_runner: runner.instance_type?, jobs_running_for_project: jobs_running_for_project(job) } job_queue_duration_seconds.observe(labels, Time.now - job.queued_at) unless job.queued_at.nil? @@ -123,10 +125,10 @@ module Ci end def jobs_running_for_project(job) - return '+Inf' unless runner.shared? + return '+Inf' unless runner.instance_type? # excluding currently started job - running_jobs_count = job.project.builds.running.where(runner: Ci::Runner.shared) + running_jobs_count = job.project.builds.running.where(runner: Ci::Runner.instance_type) .limit(JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET + 1).count - 1 running_jobs_count < JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET ? running_jobs_count : "#{JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET}+" 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/issues/base_service.rb b/app/services/issues/base_service.rb index 9f6cfc0f6d3..cbfef175af0 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -32,8 +32,9 @@ module Issues def filter_assignee(issuable) return if params[:assignee_ids].blank? - # The number of assignees is limited by one for GitLab CE - params[:assignee_ids] = params[:assignee_ids][0, 1] + unless issuable.allows_multiple_assignees? + params[:assignee_ids] = params[:assignee_ids].take(1) + end assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } 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/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb index 079f611b3f3..a72da3c637f 100644 --- a/app/services/labels/find_or_create_service.rb +++ b/app/services/labels/find_or_create_service.rb @@ -20,6 +20,7 @@ module Labels @available_labels ||= LabelsFinder.new( current_user, "#{parent_type}_id".to_sym => parent.id, + include_ancestor_groups: include_ancestor_groups?, only_group_labels: parent_is_group? ).execute(skip_authorization: skip_authorization) end @@ -30,7 +31,8 @@ module Labels new_label = available_labels.find_by(title: title) if new_label.nil? && (skip_authorization || Ability.allowed?(current_user, :admin_label, parent)) - new_label = Labels::CreateService.new(params).execute(parent_type.to_sym => parent) + create_params = params.except(:include_ancestor_groups) + new_label = Labels::CreateService.new(create_params).execute(parent_type.to_sym => parent) end new_label @@ -47,5 +49,9 @@ module Labels def parent_is_group? parent_type == "group" end + + def include_ancestor_groups? + params[:include_ancestor_groups] == true + end end end 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/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/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/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 a02a9052fb2..172497b8e67 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -2,6 +2,8 @@ module Projects class CreateService < BaseService def initialize(user, params) @current_user, @params = user, params.dup + @skip_wiki = @params.delete(:skip_wiki) + @initialize_with_readme = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_readme)) end def execute @@ -11,7 +13,6 @@ module Projects forked_from_project_id = params.delete(:forked_from_project_id) import_data = params.delete(:import_data) - @skip_wiki = params.delete(:skip_wiki) @project = Project.new(params) @@ -102,6 +103,8 @@ module Projects setup_authorizations current_user.invalidate_personal_projects_count + + create_readme if @initialize_with_readme end # Refresh the current user's authorizations inline (so they can access the @@ -116,6 +119,17 @@ module Projects end end + def create_readme + commit_attrs = { + branch_name: 'master', + commit_message: 'Initial commit', + file_path: 'README.md', + file_content: "# #{@project.name}\n\n#{@project.description}" + } + + Files::CreateService.new(@project, current_user, commit_attrs).execute + end + def skip_wiki? !@project.feature_available?(:wiki, current_user) || @skip_wiki 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/todo_service.rb b/app/services/todo_service.rb index f91cd03bf5c..46f12086555 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -260,15 +260,15 @@ class TodoService end end - def create_mention_todos(project, target, author, note = nil, skip_users = []) + def create_mention_todos(parent, target, author, note = nil, skip_users = []) # Create Todos for directly addressed users - directly_addressed_users = filter_directly_addressed_users(project, note || target, author, skip_users) - attributes = attributes_for_todo(project, target, author, Todo::DIRECTLY_ADDRESSED, note) + directly_addressed_users = filter_directly_addressed_users(parent, note || target, author, skip_users) + attributes = attributes_for_todo(parent, target, author, Todo::DIRECTLY_ADDRESSED, note) create_todos(directly_addressed_users, attributes) # Create Todos for mentioned users - mentioned_users = filter_mentioned_users(project, note || target, author, skip_users) - attributes = attributes_for_todo(project, target, author, Todo::MENTIONED, note) + mentioned_users = filter_mentioned_users(parent, note || target, author, skip_users) + attributes = attributes_for_todo(parent, target, author, Todo::MENTIONED, note) create_todos(mentioned_users, attributes) end @@ -299,36 +299,36 @@ class TodoService def attributes_for_todo(project, target, author, action, note = nil) attributes_for_target(target).merge!( - project_id: project.id, + project_id: project&.id, author_id: author.id, action: action, note: note ) end - def filter_todo_users(users, project, target) - reject_users_without_access(users, project, target).uniq + def filter_todo_users(users, parent, target) + reject_users_without_access(users, parent, target).uniq end - def filter_mentioned_users(project, target, author, skip_users = []) + def filter_mentioned_users(parent, target, author, skip_users = []) mentioned_users = target.mentioned_users(author) - skip_users - filter_todo_users(mentioned_users, project, target) + filter_todo_users(mentioned_users, parent, target) end - def filter_directly_addressed_users(project, target, author, skip_users = []) + def filter_directly_addressed_users(parent, target, author, skip_users = []) directly_addressed_users = target.directly_addressed_users(author) - skip_users - filter_todo_users(directly_addressed_users, project, target) + filter_todo_users(directly_addressed_users, parent, target) end - def reject_users_without_access(users, project, target) - if target.is_a?(Note) && (target.for_issue? || target.for_merge_request?) + def reject_users_without_access(users, parent, target) + if target.is_a?(Note) && target.for_issuable? target = target.noteable end if target.is_a?(Issuable) select_users(users, :"read_#{target.to_ability_name}", target) else - select_users(users, :read_project, project) + select_users(users, :read_project, parent) 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/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb index cd819dc9bff..0a166335b4e 100644 --- a/app/uploaders/attachment_uploader.rb +++ b/app/uploaders/attachment_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AttachmentUploader < GitlabUploader include RecordsUploads::Concern include ObjectStorage::Concern diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 5848e6c6994..b29ef57b071 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AvatarUploader < GitlabUploader include UploaderHelper include RecordsUploads::Concern diff --git a/app/uploaders/favicon_uploader.rb b/app/uploaders/favicon_uploader.rb index 3639375d474..a0b275b56a9 100644 --- a/app/uploaders/favicon_uploader.rb +++ b/app/uploaders/favicon_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FaviconUploader < AttachmentUploader EXTENSION_WHITELIST = %w[png ico].freeze diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index bd7736ad74e..a7f8615e9ba 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class FileMover attr_reader :secret, :file_name, :model, :update_field diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 36bc0a4575a..21292ddcf44 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # This class breaks the actual CarrierWave concept. # Every uploader should use a base_dir that is model agnostic so we can build # back URLs from base_dir-relative paths saved in the `Upload` model. @@ -81,6 +83,13 @@ class FileUploader < GitlabUploader apply_context!(uploader_context) end + def initialize_copy(from) + super + + @secret = self.class.generate_secret + @upload = nil # calling record_upload would delete the old upload if set + end + # enforce the usage of Hashed storage when storing to # remote store as the FileMover doesn't support OS def base_dir(store = nil) @@ -110,7 +119,7 @@ class FileUploader < GitlabUploader end def markdown_link - markdown = "[#{markdown_name}](#{secure_url})" + markdown = +"[#{markdown_name}](#{secure_url})" markdown.prepend("!") if image_or_video? || dangerous? markdown end @@ -144,6 +153,27 @@ class FileUploader < GitlabUploader @secret ||= self.class.generate_secret end + # return a new uploader with a file copy on another project + def self.copy_to(uploader, to_project) + moved = uploader.dup.tap do |u| + u.model = to_project + end + + moved.copy_file(uploader.file) + moved + end + + def copy_file(file) + to_path = if file_storage? + File.join(self.class.root, store_path) + else + store_path + end + + self.file = file.copy_to(to_path) + record_upload # after_store is not triggered + end + private def apply_context!(uploader_context) diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index f8a237178d9..7919f126075 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class GitlabUploader < CarrierWave::Uploader::Base class_attribute :options diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb index 2a5a830ce4f..855cf2fc21c 100644 --- a/app/uploaders/job_artifact_uploader.rb +++ b/app/uploaders/job_artifact_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class JobArtifactUploader < GitlabUploader extend Workhorse::UploadPath include ObjectStorage::Concern diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb index efb7893d153..b4d0d752016 100644 --- a/app/uploaders/legacy_artifact_uploader.rb +++ b/app/uploaders/legacy_artifact_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class LegacyArtifactUploader < GitlabUploader extend Workhorse::UploadPath include ObjectStorage::Concern diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index eb521a22ebc..f3d32e6b39d 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class LfsObjectUploader < GitlabUploader extend Workhorse::UploadPath include ObjectStorage::Concern diff --git a/app/uploaders/namespace_file_uploader.rb b/app/uploaders/namespace_file_uploader.rb index 1085ecb1700..52969762b7d 100644 --- a/app/uploaders/namespace_file_uploader.rb +++ b/app/uploaders/namespace_file_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NamespaceFileUploader < FileUploader # Re-Override def self.root diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index b8ecfc4ee2b..dad6e85fb56 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'fog/aws' require 'carrierwave/storage/fog' diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb index e3898b07730..25474b494ff 100644 --- a/app/uploaders/personal_file_uploader.rb +++ b/app/uploaders/personal_file_uploader.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class PersonalFileUploader < FileUploader # Re-Override def self.root diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 301f4681fcd..5795065ae11 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module RecordsUploads module Concern extend ActiveSupport::Concern diff --git a/app/uploaders/uploader_helper.rb b/app/uploaders/uploader_helper.rb index 207928b61d0..2a2b54a9270 100644 --- a/app/uploaders/uploader_helper.rb +++ b/app/uploaders/uploader_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Extra methods for uploader module UploaderHelper IMAGE_EXT = %w[png jpg jpeg gif bmp tiff ico].freeze diff --git a/app/uploaders/workhorse.rb b/app/uploaders/workhorse.rb index 782032cf516..84dc2791b9c 100644 --- a/app/uploaders/workhorse.rb +++ b/app/uploaders/workhorse.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Workhorse module UploadPath def workhorse_upload_path diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb index e43b66cbe3a..45ac695c5ec 100644 --- a/app/validators/abstract_path_validator.rb +++ b/app/validators/abstract_path_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AbstractPathValidator < ActiveModel::EachValidator extend Gitlab::EncodingHelper diff --git a/app/validators/certificate_fingerprint_validator.rb b/app/validators/certificate_fingerprint_validator.rb index 17df756183a..79d78653ec7 100644 --- a/app/validators/certificate_fingerprint_validator.rb +++ b/app/validators/certificate_fingerprint_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CertificateFingerprintValidator < ActiveModel::EachValidator FINGERPRINT_PATTERN = /\A([a-zA-Z0-9]{2}[\s\-:]?){16,}\z/.freeze diff --git a/app/validators/certificate_key_validator.rb b/app/validators/certificate_key_validator.rb index 8c7bb750339..5b2bbffc066 100644 --- a/app/validators/certificate_key_validator.rb +++ b/app/validators/certificate_key_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # UrlValidator # # Custom validator for private keys. diff --git a/app/validators/certificate_validator.rb b/app/validators/certificate_validator.rb index b0c9a1b92a4..de8bb179dfb 100644 --- a/app/validators/certificate_validator.rb +++ b/app/validators/certificate_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # UrlValidator # # Custom validator for private keys. diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb index e7d32550176..85fd63f08e5 100644 --- a/app/validators/cluster_name_validator.rb +++ b/app/validators/cluster_name_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # ClusterNameValidator # # Custom validator for ClusterName. diff --git a/app/validators/color_validator.rb b/app/validators/color_validator.rb index 571d0007aa2..1932d042e83 100644 --- a/app/validators/color_validator.rb +++ b/app/validators/color_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # ColorValidator # # Custom validator for web color codes. It requires the leading hash symbol and diff --git a/app/validators/cron_timezone_validator.rb b/app/validators/cron_timezone_validator.rb index 542c7d006ad..c5f51d65060 100644 --- a/app/validators/cron_timezone_validator.rb +++ b/app/validators/cron_timezone_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # CronTimezoneValidator # # Custom validator for CronTimezone. diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb index 981fade47a6..bd48a7a6efb 100644 --- a/app/validators/cron_validator.rb +++ b/app/validators/cron_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # CronValidator # # Custom validator for Cron. diff --git a/app/validators/duration_validator.rb b/app/validators/duration_validator.rb index 10ff44031c6..811828169ca 100644 --- a/app/validators/duration_validator.rb +++ b/app/validators/duration_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # DurationValidator # # Validate the format conforms with ChronicDuration diff --git a/app/validators/email_validator.rb b/app/validators/email_validator.rb index aab07a7ece4..9459edb7515 100644 --- a/app/validators/email_validator.rb +++ b/app/validators/email_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class EmailValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) record.errors.add(attribute, :invalid) unless value =~ Devise.email_regexp diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb index 204be827941..891d13b1596 100644 --- a/app/validators/key_restriction_validator.rb +++ b/app/validators/key_restriction_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class KeyRestrictionValidator < ActiveModel::EachValidator FORBIDDEN = -1 diff --git a/app/validators/line_code_validator.rb b/app/validators/line_code_validator.rb index ed29e5aeb67..a351180790e 100644 --- a/app/validators/line_code_validator.rb +++ b/app/validators/line_code_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # LineCodeValidator # # Custom validator for GitLab line codes. diff --git a/app/validators/namespace_name_validator.rb b/app/validators/namespace_name_validator.rb index 2e51af2982d..fb1c241037c 100644 --- a/app/validators/namespace_name_validator.rb +++ b/app/validators/namespace_name_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # NamespaceNameValidator # # Custom validator for GitLab namespace name strings. diff --git a/app/validators/namespace_path_validator.rb b/app/validators/namespace_path_validator.rb index 7b0ae4db5d4..c078b272b2f 100644 --- a/app/validators/namespace_path_validator.rb +++ b/app/validators/namespace_path_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class NamespacePathValidator < AbstractPathValidator extend Gitlab::EncodingHelper diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb index 424fd77a6a3..aea0a68e7cf 100644 --- a/app/validators/project_path_validator.rb +++ b/app/validators/project_path_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ProjectPathValidator < AbstractPathValidator extend Gitlab::EncodingHelper diff --git a/app/validators/public_url_validator.rb b/app/validators/public_url_validator.rb index 1e8118fccbb..3ff880deedd 100644 --- a/app/validators/public_url_validator.rb +++ b/app/validators/public_url_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # PublicUrlValidator # # Custom validator for URLs. This validator works like UrlValidator but diff --git a/app/validators/top_level_group_validator.rb b/app/validators/top_level_group_validator.rb index 7e2e735e0cf..b50c9dca154 100644 --- a/app/validators/top_level_group_validator.rb +++ b/app/validators/top_level_group_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class TopLevelGroupValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) if value&.subgroup? diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index 6854fec582e..faaf1283078 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # UrlValidator # # Custom validator for URLs. diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/variable_duplicates_validator.rb index 72660be6c43..90193e85f2a 100644 --- a/app/validators/variable_duplicates_validator.rb +++ b/app/validators/variable_duplicates_validator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # VariableDuplicatesValidator # # This validator is designed for especially the following condition @@ -22,8 +24,8 @@ class VariableDuplicatesValidator < ActiveModel::EachValidator def validate_duplicates(record, attribute, values) duplicates = values.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first) if duplicates.any? - error_message = "have duplicate values (#{duplicates.join(", ")})" - error_message += " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend + error_message = +"have duplicate values (#{duplicates.join(", ")})" + error_message << " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend record.errors.add(attribute, error_message) end end diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index 38607ffca1c..bd43504dd37 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -324,3 +324,6 @@ = _('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/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/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index a6cd39edcf0..43937b01339 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -1,6 +1,6 @@ %tr{ id: dom_id(runner) } %td - - if runner.shared? + - if runner.instance_type? %span.badge.badge-success shared - elsif runner.group_type? %span.badge.badge-success group @@ -21,7 +21,7 @@ %td = runner.ip_address %td - - if runner.shared? || runner.group_type? + - if runner.instance_type? || runner.group_type? n/a - else = runner.projects.count(:all) diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 8a0c2bf4c5f..62b7a4cbd07 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -2,7 +2,7 @@ %h3.project-title Runner ##{@runner.id} .float-right - - if @runner.shared? + - if @runner.instance_type? %span.runner-state.runner-state-shared Shared - else @@ -13,7 +13,7 @@ - breadcrumb_title "##{@runner.id}" - @no_container = true -- if @runner.shared? +- if @runner.instance_type? .bs-callout.bs-callout-success %h4 This Runner will process jobs from ALL UNASSIGNED projects %p diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index d5a9cc646a6..8b3974d97f8 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -30,27 +30,33 @@ .todos-filters .row-content-block.second-block - = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form' do - .filter-item.inline - - if params[:project_id].present? - = hidden_field_tag(:project_id, params[:project_id]) - = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', - placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } }) - .filter-item.inline - - if params[:author_id].present? - = hidden_field_tag(:author_id, params[:author_id]) - = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', - placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } }) - .filter-item.inline - - if params[:type].present? - = hidden_field_tag(:type, params[:type]) - = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', - data: { data: todo_types_options, default_label: 'Type' } }) - .filter-item.inline.actions-filter - - if params[:action_id].present? - = hidden_field_tag(:action_id, params[:action_id]) - = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', - data: { data: todo_actions_options, default_label: 'Action' } }) + = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do + .filter-categories.flex-fill + .filter-item.inline + - if params[:group_id].present? + = hidden_field_tag(:group_id, params[:group_id]) + = dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', + placeholder: 'Search groups', data: { data: todo_group_options, default_label: 'Group', display: 'static' } }) + .filter-item.inline + - if params[:project_id].present? + = hidden_field_tag(:project_id, params[:project_id]) + = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', + placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project', display: 'static' } }) + .filter-item.inline + - if params[:author_id].present? + = hidden_field_tag(:author_id, params[:author_id]) + = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', + placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } }) + .filter-item.inline + - if params[:type].present? + = hidden_field_tag(:type, params[:type]) + = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', + data: { data: todo_types_options, default_label: 'Type' } }) + .filter-item.inline.actions-filter + - if params[:action_id].present? + = hidden_field_tag(:action_id, params[:action_id]) + = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', + data: { data: todo_actions_options, default_label: 'Action' } }) .filter-item.sort-filter .dropdown %button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', 'data-toggle' => 'dropdown' } 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/_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/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/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/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index c23fe0b5c49..37b56f92030 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -182,6 +182,12 @@ %tr %td.shortcut %kbd g + %kbd l + %td + Go to metrics + %tr + %td.shortcut + %kbd g %kbd k %td Go to kubernetes diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 24b6c490a5a..9ed05d6e3d0 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -17,6 +17,7 @@ = link_to _("Help"), help_path - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) %li.divider + = render 'shared/user_dropdown_contributing_link' - 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 5cec443e969..d8e32651b36 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -21,7 +21,7 @@ - if current_user = render 'layouts/header/new_dropdown' - if header_link?(:search) - %li.nav-item.d-none.d-sm-none.d-md-block + %li.nav-item.d-none.d-sm-none.d-md-block.m-auto = render 'layouts/search' unless current_controller?(:search) %li.nav-item.d-inline-block.d-sm-none.d-md-none = link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index fdb07ce6fc5..00d75b3399b 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 @@ -190,7 +196,7 @@ - if project_nav_tab? :operations = nav_link(controller: [:environments, :clusters, :user, :gcp]) do - = link_to project_environments_path(@project), class: 'shortcuts-operations' do + = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do .nav-icon-container = sprite_icon('cloud-gear') %span.nav-item-name @@ -198,14 +204,19 @@ %ul.sidebar-sub-level-items = nav_link(controller: [:environments, :clusters, :user, :gcp], html_options: { class: "fly-out-top-item" } ) do - = link_to project_environments_path(@project) do + = link_to metrics_project_environments_path(@project) do %strong.fly-out-top-item-name = _('Operations') %li.divider.fly-out-top-item - if project_nav_tab? :environments - = nav_link(controller: :environments) do - = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do + = nav_link(controller: :environments, action: [:metrics, :metrics_redirect]) do + = link_to metrics_project_environments_path(@project), title: _('Metrics'), class: 'shortcuts-metrics' do + %span + = _('Metrics') + + = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do + = link_to project_environments_path(@project), title: _('Environments'), class: 'shortcuts-environments' do %span = _('Environments') @@ -278,7 +289,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 +299,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 +337,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/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/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/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/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 6f957533287..f4994f5459b 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -40,5 +40,15 @@ = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' }, target: '_blank', rel: 'noopener noreferrer' = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false +.form-group.row.initialize-with-readme-setting + %div{ :class => "col-sm-12" } + .form-check + = check_box_tag 'project[initialize_with_readme]', '1', false, class: 'form-check-input' + = label_tag 'project[initialize_with_readme]', class: 'form-check-label' do + .option-title + %strong Initialize repository with a README + .option-description + Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository. + = f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4 = link_to 'Cancel', dashboard_projects_path, class: 'btn btn-cancel' diff --git a/app/views/projects/clusters/_dropdown.html.haml b/app/views/projects/clusters/_dropdown.html.haml deleted file mode 100644 index d55a9c60b64..00000000000 --- a/app/views/projects/clusters/_dropdown.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -%h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration') - -.dropdown.clusters-dropdown - %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', data: { toggle: 'dropdown' }, 'aria-haspopup': true, 'aria-expanded': false } - %span.dropdown-toggle-text - = dropdown_text - = icon('chevron-down') - %ul.dropdown-menu.clusters-dropdown-menu.dropdown-menu-full-width - %li - = link_to(s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine'), gcp_new_namespace_project_clusters_path(@project.namespace, @project)) - %li - = link_to(s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project)) 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/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml index b8e40b0a38b..0a2e320556d 100644 --- a/app/views/projects/clusters/gcp/_form.html.haml +++ b/app/views/projects/clusters/gcp/_form.html.haml @@ -10,8 +10,10 @@ - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page} -= 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) +%p= link_to('Select a different Google account', @authorize_url) + += form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: create_gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| + = form_errors(@gcp_cluster) .form-group = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-light' = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') @@ -19,7 +21,7 @@ = 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| + = field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field| .form-group = 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' } } diff --git a/app/views/projects/clusters/gcp/_header.html.haml b/app/views/projects/clusters/gcp/_header.html.haml index fa989943492..a2ad3cd64df 100644 --- a/app/views/projects/clusters/gcp/_header.html.haml +++ b/app/views/projects/clusters/gcp/_header.html.haml @@ -1,4 +1,4 @@ -%h4.prepend-top-20 +%h4 = s_('ClusterIntegration|Enter the details for your Kubernetes cluster') %p = s_('ClusterIntegration|Please make sure that your Google account meets the following requirements:') diff --git a/app/views/projects/clusters/gcp/login.html.haml b/app/views/projects/clusters/gcp/login.html.haml deleted file mode 100644 index 55a42ac4847..00000000000 --- a/app/views/projects/clusters/gcp/login.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -- breadcrumb_title 'Kubernetes' -- page_title _("Login") - -= render_gcp_signup_offer - -.row.prepend-top-default - .col-sm-4 - = render 'projects/clusters/sidebar' - .col-sm-8 - = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine') - = render 'header' -.row - .col-sm-8.offset-sm-4.signin-with-google - - if @authorize_url - = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px'), @authorize_url) - = _('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 } diff --git a/app/views/projects/clusters/gcp/new.html.haml b/app/views/projects/clusters/gcp/new.html.haml deleted file mode 100644 index ea78d66d883..00000000000 --- a/app/views/projects/clusters/gcp/new.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- breadcrumb_title 'Kubernetes' -- page_title _("New Kubernetes Cluster") - -.row.prepend-top-default - .col-sm-4 - = render 'projects/clusters/sidebar' - .col-sm-8 - = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Create Kubernetes cluster on Google Kubernetes Engine') - = render 'header' - = render 'form' diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml index 828e2a84753..a38003f5750 100644 --- a/app/views/projects/clusters/new.html.haml +++ b/app/views/projects/clusters/new.html.haml @@ -1,15 +1,36 @@ - breadcrumb_title 'Kubernetes' - page_title _("Kubernetes Cluster") +- active_tab = local_assigns.fetch(:active_tab, 'gcp') += javascript_include_tag 'https://apis.google.com/js/api.js' = render_gcp_signup_offer .row.prepend-top-default - .col-sm-4 + .col-md-3 = render 'sidebar' - .col-sm-8 - %h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up Kubernetes cluster integration') + .col-md-9.js-toggle-container + %ul.nav-links.nav-tabs.gitlab-tabs.nav{ role: 'tablist' } + %li.nav-item{ role: 'presentation' } + %a.nav-link{ href: '#create-gcp-cluster-pane', id: 'create-gcp-cluster-tab', class: active_when(active_tab == 'gcp'), data: { toggle: 'tab' }, role: 'tab' } + %span Create new Cluster on GKE + %li.nav-item{ role: 'presentation' } + %a.nav-link{ href: '#add-user-cluster-pane', id: 'add-user-cluster-tab', class: active_when(active_tab == 'user'), data: { toggle: 'tab' }, role: 'tab' } + %span Add existing cluster - %p= s_('ClusterIntegration|Create a new Kubernetes cluster on Google Kubernetes Engine right from GitLab') - = link_to s_('ClusterIntegration|Create on Google Kubernetes Engine'), gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' - %p= s_('ClusterIntegration|Enter the details for an existing Kubernetes cluster') - = link_to s_('ClusterIntegration|Add an existing Kubernetes cluster'), user_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20' + .tab-content.gitlab-tab-content + .tab-pane{ id: 'create-gcp-cluster-pane', class: active_when(active_tab == 'gcp'), role: 'tabpanel' } + = render 'projects/clusters/gcp/header' + - if @valid_gcp_token + = render 'projects/clusters/gcp/form' + - elsif @authorize_url + .signin-with-google + = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px'), @authorize_url) + = _('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 } + + .tab-pane{ id: 'add-user-cluster-pane', class: active_when(active_tab == 'user'), role: 'tabpanel' } + = render 'projects/clusters/user/header' + = render 'projects/clusters/user/form' diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml index db57da99ec7..3006bb5073e 100644 --- a/app/views/projects/clusters/user/_form.html.haml +++ b/app/views/projects/clusters/user/_form.html.haml @@ -1,13 +1,14 @@ -= form_for @cluster, url: user_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| - = form_errors(@cluster) += form_for @user_cluster, url: create_user_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| + = form_errors(@user_cluster) .form-group = 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'), class: 'label-light' - = 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| + = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| .form-group = 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') diff --git a/app/views/projects/clusters/user/_header.html.haml b/app/views/projects/clusters/user/_header.html.haml index 37f6a788518..749177fa6c1 100644 --- a/app/views/projects/clusters/user/_header.html.haml +++ b/app/views/projects/clusters/user/_header.html.haml @@ -1,4 +1,4 @@ -%h4.prepend-top-20 +%h4 = s_('ClusterIntegration|Enter the details for your Kubernetes cluster') %p - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index', anchor: 'adding-an-existing-kubernetes-cluster'), target: '_blank', rel: 'noopener noreferrer') diff --git a/app/views/projects/clusters/user/new.html.haml b/app/views/projects/clusters/user/new.html.haml deleted file mode 100644 index 7fb75cd9cc7..00000000000 --- a/app/views/projects/clusters/user/new.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- breadcrumb_title 'Kubernetes' -- page_title _("New Kubernetes cluster") - -.row.prepend-top-default - .col-sm-4 - = render 'projects/clusters/sidebar' - .col-sm-8 - = render 'projects/clusters/dropdown', dropdown_text: s_('ClusterIntegration|Add an existing Kubernetes cluster') - = render 'header' - .prepend-top-20 - = render 'form' diff --git a/app/views/projects/deploy_tokens/_index.html.haml b/app/views/projects/deploy_tokens/_index.html.haml index 725720d2222..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' } diff --git a/app/views/projects/deploy_tokens/_revoke_modal.html.haml b/app/views/projects/deploy_tokens/_revoke_modal.html.haml index a67c3a0c841..35eacae2c2e 100644 --- a/app/views/projects/deploy_tokens/_revoke_modal.html.haml +++ b/app/views/projects/deploy_tokens/_revoke_modal.html.haml @@ -1,4 +1,4 @@ -.modal{ id: "revoke-modal-#{token.id}" } +.modal{ id: "revoke-modal-#{token.id}", tabindex: -1 } .modal-dialog .modal-content .modal-header 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/edit.html.haml b/app/views/projects/edit.html.haml index 9f175d2376f..c2d900cbcf7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -4,7 +4,7 @@ - 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 @@ -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,7 +82,7 @@ = render_if_exists 'projects/issues_settings' - %section.qa-merge-request-settings.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 @@ -101,7 +101,7 @@ = render 'export', project: @project - %section.qa-advanced-settings.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 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/empty.html.haml b/app/views/projects/environments/empty.html.haml new file mode 100644 index 00000000000..1413930ebdb --- /dev/null +++ b/app/views/projects/environments/empty.html.haml @@ -0,0 +1,14 @@ +- page_title _("Metrics") + +.row + .col-sm-12 + .svg-content + = image_tag 'illustrations/operations_metrics_empty.svg' +.row.empty-environments + .col-sm-12.text-center + %h4 + = s_('Metrics|No deployed environments') + .state-description + = s_('Metrics|Check out the CI/CD documentation on deploying to an environment') + .prepend-top-10 + = link_to s_("Metrics|Learn about environments"), help_page_path('ci/environments'), class: 'btn btn-success' diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index d6f0b230b58..290970a1045 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -2,15 +2,9 @@ - page_title "Metrics for environment", @environment.name .prometheus-container{ class: container_class } - .top-area - .row - .col-sm-6 - %h3 - Environment: - = link_to @environment.name, environment_path(@environment) - #prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'), "clusters-path": project_clusters_path(@project), + "current-environment-name": @environment.name, "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'), "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), @@ -18,6 +12,7 @@ "empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'), "metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json), "deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json), + "environments-endpoint": project_environments_path(@project, format: :json), "project-path": project_path(@project), "tags-path": project_tags_path(@project), "has-metrics": "#{@environment.has_metrics?}" } } 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/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 459150c1067..b88fe47726d 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -1,6 +1,10 @@ %aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } .sidebar-container .blocks-container + - if can?(current_user, :create_build_terminal, @build) + .block + = link_to terminal_project_job_path(@project, @build), class: 'terminal-button pull-right btn visible-md-block visible-lg-block', title: 'Terminal' do + Terminal #js-details-block-vue{ data: { can_user_retry: can?(current_user, :update_build, @build) && @build.retryable? } } @@ -14,8 +18,8 @@ #{time_ago_with_tooltip(@build.artifacts_expire_at)} - elsif @build.has_expiring_artifacts? %p.build-detail-row - The artifacts will be removed in - %span= time_ago_with_tooltip @build.artifacts_expire_at + The artifacts will be removed + #{time_ago_with_tooltip(@build.artifacts_expire_at)} - if @build.artifacts? .btn-group.d-flex{ role: :group } diff --git a/app/views/projects/jobs/terminal.html.haml b/app/views/projects/jobs/terminal.html.haml new file mode 100644 index 00000000000..efea666a4d9 --- /dev/null +++ b/app/views/projects/jobs/terminal.html.haml @@ -0,0 +1,11 @@ +- @no_container = true +- add_to_breadcrumbs 'Jobs', project_jobs_path(@project) +- add_to_breadcrumbs "##{@build.id}", project_job_path(@project, @build) +- breadcrumb_title 'Terminal' +- page_title 'Terminal', "#{@build.name} (##{@build.id})", 'Jobs' + +- content_for :page_specific_javascripts do + = stylesheet_link_tag "xterm/xterm" + +.terminal-container{ class: container_class } + #terminal{ data: { project_path: terminal_project_job_path(@project, @build, format: :ws) } } 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 b2eacabc21a..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 - = 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/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 2f1877a15c2..b23baa22d8b 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -52,24 +52,7 @@ 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 @@ -77,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/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/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index fe2903b456f..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 diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index a23f5d6f0c3..6ee83fae25e 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -26,7 +26,7 @@ - else - runner_project = @project.runner_projects.find_by(runner_id: runner) = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' - - elsif !(runner.is_shared? || runner.group_type?) # We can simplify this to `runner.project_type?` when migrating #runner_type is complete + - elsif runner.project_type? = form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f| = f.hidden_field :runner_id, value: runner.id = f.submit _('Enable for this project'), class: 'btn btn-sm' 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 b20614dc88f..f51dd581d29 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -7,7 +7,7 @@ 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') + = sprite_icon('external-link', size: 16) %p.inline See list of available commands in Mattermost after setting up this service, by entering 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 9d045d84b52..f25d2ecdfb1 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -8,7 +8,7 @@ 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') + = sprite_icon('external-link', size: 16) %p.inline See list of available commands in Slack after setting up this service, by entering @@ -20,7 +20,7 @@ 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') + = sprite_icon('external-link', size: 16) in your Slack team with these options: %hr 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 4359362bb05..31c2616d283 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -63,4 +63,4 @@ .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" + = 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 7142a9d635e..fb113aa7639 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 hide js-secret-value", placeholder: 'xEeFCaDAB89' - %p.form-text.text-muted The secure token used by the Runner to checkout the project + %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) - @@ -119,8 +117,11 @@ %li JaCoCo (Java/Kotlin) %code Total.*?([0-9]{1,3})% + %li + go test -cover (Go) + %code coverage: \d+.\d+% of statements - = 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 56c175f5649..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,11 +8,11 @@ %section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) } .settings-header %h4 - General pipelines + = _("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' @@ -31,11 +31,11 @@ %section.qa-runners-settings.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - Runners + = _("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' @@ -45,21 +45,19 @@ = _('Variables') = 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/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index b3a9fa9dd91..4a3aa3dc626 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -3,34 +3,34 @@ .d-none.d-sm-block - if can?(current_user, :update_project_snippet, @snippet) = link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do - Edit + = _('Edit') - if can?(current_user, :update_project_snippet, @snippet) - = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do - Delete + = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do + = _('Delete') - if can?(current_user, :create_project_snippet, @project) - = link_to new_project_snippet_path(@project), class: 'btn btn-grouped btn-inverted btn-create', title: "New snippet" do - New snippet + = link_to new_project_snippet_path(@project), class: 'btn btn-grouped btn-inverted btn-create', title: _("New snippet") do + = _('New snippet') - if @snippet.submittable_as_spam_by?(current_user) - = link_to 'Submit as spam', mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam') - if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet) .d-block.d-sm-none.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } - Options + = _('Options') = icon('caret-down') .dropdown-menu.dropdown-menu-full-width %ul - if can?(current_user, :create_project_snippet, @project) %li - = link_to new_project_snippet_path(@project), title: "New snippet" do - New snippet + = link_to new_project_snippet_path(@project), title: _("New snippet") do + = _('New snippet') - if can?(current_user, :update_project_snippet, @snippet) %li - = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do - Delete + = link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do + = _('Delete') - if can?(current_user, :update_project_snippet, @snippet) %li = link_to edit_project_snippet_path(@project, @snippet) do - Edit + = _('Edit') - if @snippet.submittable_as_spam_by?(current_user) %li - = link_to 'Submit as spam', mark_as_spam_project_snippet_path(@project, @snippet), method: :post + = link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml index 32844f5204a..6dbd67df886 100644 --- a/app/views/projects/snippets/edit.html.haml +++ b/app/views/projects/snippets/edit.html.haml @@ -1,8 +1,8 @@ -- add_to_breadcrumbs "Snippets", project_snippets_path(@project) +- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title @snippet.to_reference -- page_title "Edit", "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" +- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") %h3.page-title - Edit Snippet + = _("Edit Snippet") %hr = render "shared/snippets/form", url: project_snippet_path(@project, @snippet) diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index 65efc083fdd..1c4c73dc776 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -1,4 +1,4 @@ -- page_title "Snippets" +- page_title _("Snippets") - if current_user .top-area @@ -7,6 +7,6 @@ .nav-controls - if can?(current_user, :create_project_snippet, @project) - = link_to "New snippet", new_project_snippet_path(@project), class: "btn btn-new", title: "New snippet" + = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-new", title: _("New snippet") = render 'snippets/snippets' diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml index 1359a815429..26b333d4ecf 100644 --- a/app/views/projects/snippets/new.html.haml +++ b/app/views/projects/snippets/new.html.haml @@ -1,8 +1,8 @@ -- add_to_breadcrumbs "Snippets", project_snippets_path(@project) -- breadcrumb_title "New" -- page_title "New Snippets" +- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) +- breadcrumb_title _("New") +- page_title _("New Snippets") %h3.page-title - New Snippet + = _('New Snippet') %hr = render "shared/snippets/form", url: project_snippets_path(@project, @snippet) diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 7062c5b765e..f495b4eaf30 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -1,7 +1,7 @@ - @content_class = "limit-container-width limited-inner-width-container" unless fluid_layout -- add_to_breadcrumbs "Snippets", project_snippets_path(@project) +- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title @snippet.to_reference -- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" +- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") = render 'shared/snippets/header' diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 5eec7b02b54..e93925b5ef9 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -21,8 +21,9 @@ = 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) %li.inline - = link_to edit_label_path(label), class: 'btn btn-transparent label-action', aria_label: 'Edit label' do + = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit', aria_label: 'Edit label' do = sprite_icon('pencil') %li.inline .dropdown @@ -42,9 +43,10 @@ container: 'body', toggle: 'modal' } } = _('Promote to group label') - %li - %span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } } - %button.text-danger.remove-row{ type: 'button' }= _('Delete') + - 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 %li.inline.label-subscription - if can_subscribe_to_label_in_different_levels?(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/_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/_user_dropdown_contributing_link.html.haml b/app/views/shared/_user_dropdown_contributing_link.html.haml new file mode 100644 index 00000000000..333d6fa3489 --- /dev/null +++ b/app/views/shared/_user_dropdown_contributing_link.html.haml @@ -0,0 +1,5 @@ +%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 diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 496b94ec953..28e6fe1b16d 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -2,19 +2,20 @@ - group = local_assigns.fetch(:group, false) - @no_breadcrumb_container = true - @no_container = true -- @content_class = "issue-boards-content" -- breadcrumb_title "Issue Board" -- page_title "Boards" +- @content_class = "issue-boards-content js-focus-mode-board" +- breadcrumb_title _("Issue Boards") +- page_title _("Boards") - content_for :page_specific_javascripts do -# haml-lint:disable InlineJavaScript %script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board" %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal + %script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board" #board-app.boards-app{ "v-cloak" => true, data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" } .d-none.d-sm-none.d-md-block - = render 'shared/issuable/search_bar', type: :boards + = render 'shared/issuable/search_bar', type: :boards, board: board .boards-list .boards-app-loading.text-center{ "v-if" => "loading" } diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index 76843ce7cc0..03e008f5fa0 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -30,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", @@ -55,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 daee691e358..607e7f471c9 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -1,12 +1,12 @@ .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" } .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" } @@ -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/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/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index ef9ea2194ee..9ce7f6fe269 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -1,9 +1,14 @@ - type = local_assigns.fetch(:type) +- board = local_assigns.fetch(:board, nil) - block_css_class = type != :boards_modal ? 'row-content-block second-block' : '' - full_path = @project.present? ? @project.full_path : @group.full_path +- user_can_admin_list = board && can?(current_user, :admin_list, board.parent) .issues-filters .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal } + - if type == :boards + #js-multiple-boards-switcher.inline.boards-switcher{ "v-cloak" => true } + = render_if_exists "shared/boards/switcher", board: board = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do - if params[:search].present? = hidden_field_tag :search, params[:search] @@ -99,13 +104,18 @@ %gl-emoji %span.js-data-value.prepend-left-10 {{name}} + + = render_if_exists 'shared/issuable/filter_weight', type: type + %button.clear-search.hidden{ type: 'button' } = icon('times') .filter-dropdown-container - if type == :boards - - if can?(current_user, :admin_list, board.parent) - = render_if_exists 'shared/issuable/board_create_list_dropdown', board: board + .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } + - if user_can_admin_list + = render '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) } } + #js-toggle-focus-btn - elsif type != :boards_modal = render 'shared/sort_dropdown' 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/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/_form.html.haml b/app/views/shared/notes/_form.html.haml index c360f1ffe2a..6b2715b47a7 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -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 526330f4e50..d4e8f30e458 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -59,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/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 88f0675f795..6be1fb485a4 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -4,7 +4,7 @@ - ci = false unless local_assigns[:ci] == true - skip_namespace = false unless local_assigns[:skip_namespace] == true - user = local_assigns[:user] -- access = user&.max_member_access_for_project(project.id) unless user.nil? +- access = max_project_member_access(project) - css_class = '' unless local_assigns[:css_class] - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project) - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/show.html.haml index e50b7fa68dd..362569bfbaf 100644 --- a/app/views/shared/runners/show.html.haml +++ b/app/views/shared/runners/show.html.haml @@ -3,7 +3,7 @@ %h3.page-title Runner ##{@runner.id} .float-right - - if @runner.shared? + - if @runner.instance_type? %span.runner-state.runner-state-shared Shared - elsif @runner.group_type? @@ -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 2d0bb722189..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, class: "label-light" - %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/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 30b6796a7d6..b8b854853b7 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -11,7 +11,7 @@ - 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 @@ -20,6 +20,7 @@ - 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 @@ -71,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 @@ -118,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..c6f89a17729 100644 --- a/app/workers/archive_trace_worker.rb +++ b/app/workers/archive_trace_worker.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + class ArchiveTraceWorker include ApplicationWorker include PipelineBackgroundQueue def perform(job_id) - Ci::Build.find_by(id: job_id).try do |job| + Ci::Build.without_archived_trace.find_by(id: job_id).try do |job| job.trace.archive! end end 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 index 2ac65f41f4e..7d4e9660a4e 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Ci class ArchiveTracesCronWorker include ApplicationWorker @@ -10,6 +12,7 @@ module Ci Ci::Build.finished.with_live_trace.find_each(batch_size: 100) do |build| begin build.trace.archive! + rescue ::Gitlab::Ci::Trace::AlreadyArchivedError rescue => e failed_archive_counter.increment Rails.logger.error "Failed to archive stale live trace. id: #{build.id} message: #{e.message}" 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 ae5c5fac834..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 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..b0e1d8837d9 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Worker for updating any project specific caches. class ProjectCacheWorker include ApplicationWorker 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 dbb215f1964..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 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 0aff0c4c7c6..fa76fbac55c 100644 --- a/app/workers/storage_migrator_worker.rb +++ b/app/workers/storage_migrator_worker.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class StorageMigratorWorker include ApplicationWorker 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 |