diff options
author | Simon Knox <psimyn@gmail.com> | 2017-09-06 14:35:58 +1000 |
---|---|---|
committer | Simon Knox <psimyn@gmail.com> | 2017-09-06 14:35:58 +1000 |
commit | b9aa55e1ea2ba226bd9bf4c6fb08fdec30e046c5 (patch) | |
tree | b7770180f178086c78ef2ca25d6bb2267f739110 /app | |
parent | 74740604211dab6632771f1bfd7dd67902fea7ef (diff) | |
parent | d68ff7f50a93ebbff537b5e795cf6bf80bd66a6e (diff) | |
download | gitlab-ce-b9aa55e1ea2ba226bd9bf4c6fb08fdec30e046c5.tar.gz |
Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce into ee_issue_928_backport
Diffstat (limited to 'app')
343 files changed, 6924 insertions, 1902 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 78cb3def879..8acddd6194c 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,7 +5,7 @@ const Api = { groupPath: '/api/:version/groups/:id.json', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', - projectsPath: '/api/:version/projects.json?simple=true', + projectsPath: '/api/:version/projects.json', labelsPath: '/:namespace_path/:project_path/labels', licensePath: '/api/:version/templates/licenses/:key', gitignorePath: '/api/:version/templates/gitignores/:key', @@ -58,6 +58,7 @@ const Api = { const defaults = { search: query, per_page: 20, + simple: true, }; if (gon.current_user_id) { diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index cfab6c40b34..4d2d4db7c0e 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -2,17 +2,17 @@ import AccessorUtilities from './lib/utils/accessor'; window.Autosave = (function() { - function Autosave(field, key) { + function Autosave(field, key, resource) { this.field = field; this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - + this.resource = resource; if (key.join != null) { - key = key.join("/"); + key = key.join('/'); } - this.key = "autosave/" + key; - this.field.data("autosave", this); + this.key = 'autosave/' + key; + this.field.data('autosave', this); this.restore(); - this.field.on("input", (function(_this) { + this.field.on('input', (function(_this) { return function() { return _this.save(); }; @@ -29,7 +29,17 @@ window.Autosave = (function() { if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } - return this.field.trigger("input"); + if (!this.resource && this.resource !== 'issue') { + this.field.trigger('input'); + } else { + // v-model does not update with jQuery trigger + // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 + const event = new Event('change', { bubbles: true, cancelable: false }); + const field = this.field.get(0); + if (field) { + field.dispatchEvent(event); + } + } }; Autosave.prototype.save = function() { diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 097f79a250a..22fa1f2a609 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -109,6 +109,7 @@ class AwardsHandler { } $thumbsBtn.toggleClass('disabled', $userAuthored); + $thumbsBtn.prop('disabled', $userAuthored); } // Create the emoji menu with the first category of emojis. @@ -234,14 +235,33 @@ class AwardsHandler { } addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { + const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; + + if (gl.utils.isInIssuePage() && !isMainAwardsBlock) { + const id = votesBlock.attr('id').replace('note_', ''); + + $('.emoji-menu').removeClass('is-visible'); + $('.js-add-award.is-active').removeClass('is-active'); + const toggleAwardEvent = new CustomEvent('toggleAward', { + detail: { + awardName: emoji, + noteId: id, + }, + }); + + document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent); + } + const normalizedEmoji = this.emoji.normalizeEmojiName(emoji); const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); + this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => { this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); return typeof callback === 'function' ? callback() : undefined; }); + $('.emoji-menu').removeClass('is-visible'); - $('.js-add-award.is-active').removeClass('is-active'); + return $('.js-add-award.is-active').removeClass('is-active'); } addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { @@ -268,6 +288,14 @@ class AwardsHandler { } getVotesBlock() { + if (gl.utils.isInIssuePage()) { + const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); + + if ($el.length) { + return $el; + } + } + const currentBlock = $('.js-awards-block.current'); let resultantVotesBlock = currentBlock; if (currentBlock.length === 0) { diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index bc693616460..79702c54852 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { if (!$submitButton.attr('disabled')) { $submitButton.trigger('click', [e]); - $submitButton.disable(); + + if (!gl.utils.isInIssuePage()) { + $submitButton.disable(); + } } }); diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index 6db8b3afbef..768453b28f1 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -2,3 +2,4 @@ import 'underscore'; import './polyfills'; import './jquery'; import './bootstrap'; +import './vue'; diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index bc3e741f524..b78089525cc 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -12,3 +12,4 @@ import 'core-js/fn/symbol'; // Browser polyfills import './polyfills/custom_event'; import './polyfills/element'; +import './polyfills/nodelist'; diff --git a/app/assets/javascripts/commons/polyfills/nodelist.js b/app/assets/javascripts/commons/polyfills/nodelist.js new file mode 100644 index 00000000000..3772c94b900 --- /dev/null +++ b/app/assets/javascripts/commons/polyfills/nodelist.js @@ -0,0 +1,7 @@ +if (window.NodeList && !NodeList.prototype.forEach) { + NodeList.prototype.forEach = function forEach(callback, thisArg = window) { + for (let i = 0; i < this.length; i += 1) { + callback.call(thisArg, this[i], i, this); + } + }; +} diff --git a/app/assets/javascripts/vue_shared/common_vue.js b/app/assets/javascripts/commons/vue.js index eb2a6071fda..8b62d78c043 100644 --- a/app/assets/javascripts/vue_shared/common_vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import './vue_resource_interceptor'; if (process.env.NODE_ENV !== 'production') { Vue.config.productionTip = false; 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 c37249c060a..06ce84d7599 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -21,11 +21,13 @@ const DiffNoteAvatars = Vue.extend({ }, template: ` <div class="diff-comment-avatar-holders" + :class="discussionClassName" v-show="notesCount !== 0"> <div v-if="!isVisible"> <!-- FIXME: Pass an alt attribute here for accessibility --> <user-avatar-image v-for="note in notesSubset" + :key="note.id" class="diff-comment-avatar js-diff-comment-avatar" @click.native="clickedAvatar($event)" :img-src="note.authorAvatar" @@ -68,7 +70,8 @@ const DiffNoteAvatars = Vue.extend({ }); }); }, - destroyed() { + beforeDestroy() { + this.addNoCommentClass(); $(document).off('toggle.comments'); }, watch: { @@ -85,6 +88,9 @@ const DiffNoteAvatars = Vue.extend({ }, }, computed: { + discussionClassName() { + return `js-diff-avatars-${this.discussionId}`; + }, notesSubset() { let notes = []; diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 5decfc1dc01..0863c3406bd 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -32,6 +32,10 @@ $(() => { const tmpApp = new tmp().$mount(); $(this).replaceWith(tmpApp.$el); + $(tmpApp.$el).one('remove.vue', () => { + tmpApp.$destroy(); + tmpApp.$el.remove(); + }); }); const $components = $(COMPONENT_SELECTOR).filter(function () { diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index b71c449090e..3dec4de06ec 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -74,6 +74,7 @@ import PerformanceBar from './performance_bar'; import initNotes from './init_notes'; import initLegacyFilters from './init_legacy_filters'; import initIssuableSidebar from './init_issuable_sidebar'; +import initProjectVisibilitySelector from './project_visibility'; import GpgBadges from './gpg_badges'; import UserFeatureHelper from './helpers/user_feature_helper'; import initChangesDropdown from './init_changes_dropdown'; @@ -98,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown'; path = page.split(':'); shortcut_handler = null; - $('.js-gfm-input').each((i, el) => { + $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete); gfm.setup($(el), { @@ -171,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown'; shortcut_handler = new ShortcutsIssuable(); new ZenMode(); initIssuableSidebar(); - initNotes(); break; case 'dashboard:milestones:index': new ProjectSelect(); @@ -575,6 +575,7 @@ import initChangesDropdown from './init_changes_dropdown'; break; case 'new': new ProjectNew(); + initProjectVisibilitySelector(); break; case 'show': new Star(); diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 70cd337fb8a..3901bb177fe 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -85,6 +85,13 @@ class DropDown { const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; renderableList.innerHTML = children.join(''); + + const listEvent = new CustomEvent('render.dl', { + detail: { + list: this, + }, + }); + this.list.dispatchEvent(listEvent); } renderChildren(data) { diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 6d19a6d9b3a..975903159be 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -128,7 +128,7 @@ window.DropzoneInput = (function() { // removeAllFiles(true) stops uploading files (if any) // and remove them from dropzone files queue. $cancelButton.on('click', (e) => { - const target = e.target.closest('form').querySelector('.div-dropzone'); + const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone'); e.preventDefault(); e.stopPropagation(); @@ -140,7 +140,7 @@ window.DropzoneInput = (function() { // and add that files to the dropzone files queue again. // addFile() adds file to dropzone files queue and upload it. $retryLink.on('click', (e) => { - const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); + const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone')); const failedFiles = dropzoneInstance.files; e.preventDefault(); diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js new file mode 100644 index 00000000000..800ca05cd11 --- /dev/null +++ b/app/assets/javascripts/feature_highlight/feature_highlight.js @@ -0,0 +1,61 @@ +import Cookies from 'js-cookie'; +import _ from 'underscore'; +import { + getCookieName, + getSelector, + hidePopover, + setupDismissButton, + mouseenter, + mouseleave, +} from './feature_highlight_helper'; + +export const setupFeatureHighlightPopover = (id, debounceTimeout = 300) => { + const $selector = $(getSelector(id)); + const $parent = $selector.parent(); + const $popoverContent = $parent.siblings('.feature-highlight-popover-content'); + const hideOnScroll = hidePopover.bind($selector); + const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout); + + $selector + // Setup popover + .data('content', $popoverContent.prop('outerHTML')) + .popover({ + html: true, + // Override the existing template to add custom CSS classes + template: ` + <div class="popover feature-highlight-popover" role="tooltip"> + <div class="arrow"></div> + <div class="popover-content"></div> + </div> + `, + }) + .on('mouseenter', mouseenter) + .on('mouseleave', debouncedMouseleave) + .on('inserted.bs.popover', setupDismissButton) + .on('show.bs.popover', () => { + window.addEventListener('scroll', hideOnScroll); + }) + .on('hide.bs.popover', () => { + window.removeEventListener('scroll', hideOnScroll); + }) + // Display feature highlight + .removeAttr('disabled'); +}; + +export const shouldHighlightFeature = (id) => { + const element = document.querySelector(getSelector(id)); + const previouslyDismissed = Cookies.get(getCookieName(id)) === 'true'; + + return element && !previouslyDismissed; +}; + +export const highlightFeatures = (highlightOrder) => { + const featureId = highlightOrder.find(shouldHighlightFeature); + + if (featureId) { + setupFeatureHighlightPopover(featureId); + return true; + } + + return false; +}; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js new file mode 100644 index 00000000000..9f741355cd7 --- /dev/null +++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js @@ -0,0 +1,57 @@ +import Cookies from 'js-cookie'; + +export const getCookieName = cookieId => `feature-highlighted-${cookieId}`; +export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`; + +export const showPopover = function showPopover() { + if (this.hasClass('js-popover-show')) { + return false; + } + this.popover('show'); + this.addClass('disable-animation js-popover-show'); + + return true; +}; + +export const hidePopover = function hidePopover() { + if (!this.hasClass('js-popover-show')) { + return false; + } + this.popover('hide'); + this.removeClass('disable-animation js-popover-show'); + + return true; +}; + +export const dismiss = function dismiss(cookieId) { + Cookies.set(getCookieName(cookieId), true); + hidePopover.call(this); + this.hide(); +}; + +export const mouseleave = function mouseleave() { + if (!$('.popover:hover').length > 0) { + const $featureHighlight = $(this); + hidePopover.call($featureHighlight); + } +}; + +export const mouseenter = function mouseenter() { + const $featureHighlight = $(this); + + const showedPopover = showPopover.call($featureHighlight); + if (showedPopover) { + $('.popover') + .on('mouseleave', mouseleave.bind($featureHighlight)); + } +}; + +export const setupDismissButton = function setupDismissButton() { + const popoverId = this.getAttribute('aria-describedby'); + const cookieId = this.dataset.highlight; + const $popover = $(this); + const dismissWrapper = dismiss.bind($popover, cookieId); + + $(`#${popoverId} .dismiss-feature-highlight`) + .on('click', dismissWrapper); +}; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js new file mode 100644 index 00000000000..fd48f2e87cc --- /dev/null +++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js @@ -0,0 +1,12 @@ +import { highlightFeatures } from './feature_highlight'; +import bp from '../breakpoints'; + +const highlightOrder = ['issue-boards']; + +export default function domContentLoaded(order) { + if (bp.getBreakpointSize() === 'lg') { + highlightFeatures(order); + } +} + +document.addEventListener('DOMContentLoaded', domContentLoaded.bind(this, highlightOrder)); diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js new file mode 100644 index 00000000000..f9bbbf0cbc1 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -0,0 +1,82 @@ +/* global Flash */ + +import Ajax from '~/droplab/plugins/ajax'; +import Filter from '~/droplab/plugins/filter'; +import './filtered_search_dropdown'; + +class DropdownEmoji extends gl.FilteredSearchDropdown { + constructor(options = {}) { + super(options); + this.config = { + Ajax: { + endpoint: `${gon.relative_url_root || ''}/autocomplete/award_emojis`, + method: 'setData', + loadingTemplate: this.loadingTemplate, + onError() { + /* eslint-disable no-new */ + new Flash('An error occured fetching the dropdown data.'); + /* eslint-enable no-new */ + }, + }, + Filter: { + template: 'name', + }, + }; + + import(/* webpackChunkName: 'emoji' */ '~/emoji') + .then(({ glEmojiTag }) => { this.glEmojiTag = glEmojiTag; }) + .catch(() => { /* ignore error and leave emoji name in the search bar */ }); + + this.unbindEvents(); + this.bindEvents(); + } + + bindEvents() { + super.bindEvents(); + + this.listRenderedWrapper = this.listRendered.bind(this); + this.dropdown.addEventListener('render.dl', this.listRenderedWrapper); + } + + unbindEvents() { + this.dropdown.removeEventListener('render.dl', this.listRenderedWrapper); + super.unbindEvents(); + } + + listRendered() { + this.replaceEmojiElement(); + } + + itemClicked(e) { + super.itemClicked(e, (selected) => { + const name = selected.querySelector('.js-data-value').innerText.trim(); + return gl.DropdownUtils.getEscapedText(name); + }); + } + + renderContent(forceShowList = false) { + this.droplab.changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config); + super.renderContent(forceShowList); + } + + replaceEmojiElement() { + if (!this.glEmojiTag) return; + + // Replace empty gl-emoji tag to real content + const dropdownItems = [...this.dropdown.querySelectorAll('.filter-dropdown-item')]; + dropdownItems.forEach((dropdownItem) => { + const name = dropdownItem.querySelector('.js-data-value').innerText; + const emojiTag = this.glEmojiTag(name); + const emojiElement = dropdownItem.querySelector('gl-emoji'); + emojiElement.outerHTML = emojiTag; + }); + } + + init() { + this.droplab + .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init(); + } +} + +window.gl = window.gl || {}; +gl.DropdownEmoji = DropdownEmoji; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index a81389ab088..23040cd9eb8 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -61,7 +61,7 @@ class DropdownHint extends gl.FilteredSearchDropdown { .map(tokenKey => ({ icon: `fa-${tokenKey.icon}`, hint: tokenKey.key, - tag: `<${tokenKey.symbol}${tokenKey.key}>`, + tag: `:${tokenKey.tag}`, type: tokenKey.type, })); diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 132b6fe698a..6d5dd747224 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -1,3 +1,4 @@ +import './dropdown_emoji'; import './dropdown_hint'; import './dropdown_non_user'; import './dropdown_user'; 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 dd1c067df87..46c80dfd45e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -58,6 +58,11 @@ class FilteredSearchDropdownManager { }, element: this.container.querySelector('#js-dropdown-label'), }, + 'my-reaction': { + reference: null, + gl: 'DropdownEmoji', + element: this.container.querySelector('#js-dropdown-my-reaction'), + }, hint: { reference: null, gl: 'DropdownHint', diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index a31be2b0bc7..038239bf466 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -439,8 +439,13 @@ class FilteredSearchManager { const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); if (match) { - const indexOf = keyParam.indexOf('_'); - const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; + // Use lastIndexOf because the token key is allowed to contain underscore + // e.g. 'my_reaction' is the token key of 'my_reaction_emoji' + const lastIndexOf = keyParam.lastIndexOf('_'); + let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam; + // Replace underscore with hyphen in the sanitizedkey. + // e.g. 'my_reaction' => 'my-reaction' + sanitizedKey = sanitizedKey.replace('_', '-'); const symbol = match.symbol; let quotationsToUse = ''; @@ -515,7 +520,10 @@ class FilteredSearchManager { const condition = this.filteredSearchTokenKeys .searchByConditionKeyValue(token.key, token.value.toLowerCase()); const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; - const keyParam = param ? `${token.key}_${param}` : token.key; + // Replace hyphen with underscore to use as request parameter + // e.g. 'my-reaction' => 'my_reaction' + const underscoredKey = token.key.replace('-', '_'); + const keyParam = param ? `${underscoredKey}_${param}` : underscoredKey; let tokenPath = ''; if (condition) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 025d4d8795b..be595d7df1a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -4,26 +4,42 @@ const tokenKeys = [{ param: 'username', symbol: '@', icon: 'pencil', + tag: '@author', }, { key: 'assignee', type: 'string', param: 'username', symbol: '@', icon: 'user', + tag: '@assignee', }, { key: 'milestone', type: 'string', param: 'title', symbol: '%', icon: 'clock-o', + tag: '%milestone', }, { key: 'label', type: 'array', param: 'name[]', symbol: '~', icon: 'tag', + tag: '~label', }]; +if (gon.current_user_id) { + // Appending tokenkeys only logged-in + tokenKeys.push({ + key: 'my-reaction', + type: 'string', + param: 'emoji', + symbol: '', + icon: 'thumbs-up', + tag: 'emoji', + }); +} + const alternativeTokenKeys = [{ key: 'label', type: 'string', @@ -84,6 +100,10 @@ class FilteredSearchTokenKeys { return tokenKeysWithAlternative.find((tokenKey) => { let tokenKeyParam = tokenKey.key; + // Replace hyphen with underscore to compare keyParam with tokenKeyParam + // e.g. 'my-reaction' => 'my_reaction' + tokenKeyParam = tokenKeyParam.replace('-', '_'); + if (tokenKey.param) { tokenKeyParam += `_${tokenKey.param}`; } 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 243ee4d723a..28e8240169d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -132,6 +132,23 @@ class FilteredSearchVisualTokens { .catch(() => { }); } + static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { + const container = tokenValueContainer; + const element = tokenValueElement; + + return import(/* webpackChunkName: 'emoji' */ '../emoji') + .then((Emoji) => { + if (!Emoji.isEmojiNameValid(tokenValue)) { + return; + } + + container.dataset.originalValue = tokenValue; + element.innerHTML = Emoji.glEmojiTag(tokenValue); + }) + // ignore error and leave emoji name in the search bar + .catch(() => { }); + } + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); @@ -144,6 +161,10 @@ class FilteredSearchVisualTokens { FilteredSearchVisualTokens.updateUserTokenAppearance( tokenValueContainer, tokenValueElement, tokenValue, ); + } else if (tokenType === 'my-reaction') { + FilteredSearchVisualTokens.updateEmojiTokenAppearance( + tokenValueContainer, tokenValueElement, tokenValue, + ); } } diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 32cb42c8b10..063155a167a 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,4 +1,3 @@ -import Cookies from 'js-cookie'; import bp from './breakpoints'; const HIDE_INTERVAL_TIMEOUT = 300; @@ -8,9 +7,12 @@ const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out'; let currentOpenMenu = null; let menuCornerLocs; let timeoutId; +let sidebar; export const mousePos = []; +export const setSidebar = (el) => { sidebar = el; }; +export const getOpenMenu = () => currentOpenMenu; export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; }; export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); @@ -20,10 +22,8 @@ let headerHeight = 50; export const getHeaderHeight = () => headerHeight; export const canShowActiveSubItems = (el) => { - const isHiddenByMedia = bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md'; - - if (el.classList.contains('active') && !isHiddenByMedia) { - return Cookies.get('sidebar_collapsed') === 'true'; + if (el.classList.contains('active') && (sidebar && !sidebar.classList.contains('sidebar-icons-only'))) { + return false; } return true; @@ -142,14 +142,22 @@ export const documentMouseMove = (e) => { if (mousePos.length > 6) mousePos.shift(); }; +export const subItemsMouseLeave = (relatedTarget) => { + clearTimeout(timeoutId); + + if (!relatedTarget.closest(`.${IS_OVER_CLASS}`)) { + hideMenu(currentOpenMenu); + } +}; + export default () => { - const sidebar = document.querySelector('.sidebar-top-level-items'); + sidebar = document.querySelector('.nav-sidebar'); if (!sidebar) return; const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')]; - sidebar.addEventListener('mouseleave', () => { + sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => { clearTimeout(timeoutId); timeoutId = setTimeout(() => { @@ -163,10 +171,7 @@ export default () => { const subItems = el.querySelector('.sidebar-sub-level-items'); if (subItems) { - subItems.addEventListener('mouseleave', () => { - clearTimeout(timeoutId); - hideMenu(currentOpenMenu); - }); + subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget)); } el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget)); diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index b62acfcd445..6f7671aa6fe 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -486,7 +486,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.shouldPropagate = function(e) { var $target; - if (this.options.multiSelect) { + if (this.options.multiSelect || this.options.shouldPropagate === false) { $target = $(e.target); if ($target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && @@ -546,10 +546,10 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.positionMenuAbove = function() { - var $button = $(this.el); var $menu = this.dropdown.find('.dropdown-menu'); - $menu.css('top', ($button.height() + $menu.height()) * -1); + $menu.css('top', 'initial'); + $menu.css('bottom', '100%'); }; GitLabDropdown.prototype.hidden = function(e) { @@ -637,11 +637,15 @@ GitLabDropdown = (function() { value = this.options.id ? this.options.id(data) : data.id; fieldName = this.options.fieldName; - if (value) { value = value.toString().replace(/'/g, '\\\''); } - - field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); - if (field.length) { - selected = true; + if (value) { + value = value.toString().replace(/'/g, '\\\''); + field = this.dropdown.parent().find(`input[name='${fieldName}'][value='${value}']`); + if (field.length) { + selected = true; + } + } else { + field = this.dropdown.parent().find(`input[name='${fieldName}']`); + selected = !field.length; } } // Set URL @@ -698,7 +702,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.noResults = function() { var html; - return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>"; + return html = '<li class="dropdown-menu-empty-link"><a href="#" class="is-focused">No matching results</a></li>'; }; GitLabDropdown.prototype.rowClicked = function(el) { diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index cb133cf7535..2060410e991 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -1,10 +1,10 @@ <script> +import identicon from '../../vue_shared/components/identicon.vue'; import eventHub from '../event_hub'; -import groupIdenticon from './group_identicon.vue'; export default { components: { - groupIdenticon, + identicon, }, props: { group: { @@ -205,7 +205,7 @@ export default { class="avatar s40" :src="group.avatarUrl" /> - <group-identicon + <identicon v-else :entity-id=group.id :entity-name="group.name" diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 3f848e0859b..470c39c6f76 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -10,8 +10,6 @@ import ZenMode from './zen_mode'; (function() { this.IssuableForm = (function() { - IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; - IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; function IssuableForm(form) { @@ -26,7 +24,6 @@ import ZenMode from './zen_mode'; new ZenMode(); this.titleField = this.form.find("input[name*='[title]']"); this.descriptionField = this.form.find("textarea[name*='[description]']"); - this.issueMoveField = this.form.find("#move_to_project_id"); if (!(this.titleField.length && this.descriptionField.length)) { return; } @@ -34,7 +31,6 @@ import ZenMode from './zen_mode'; this.form.on("submit", this.handleSubmit); this.form.on("click", ".btn-cancel", this.resetAutosave); this.initWip(); - this.initMoveDropdown(); $issuableDueDate = $('#issuable-due-date'); if ($issuableDueDate.length) { calendar = new Pikaday({ @@ -56,12 +52,6 @@ import ZenMode from './zen_mode'; }; IssuableForm.prototype.handleSubmit = function() { - var fieldId = (this.issueMoveField != null) ? this.issueMoveField.val() : null; - if ((parseInt(fieldId, 10) || 0) > 0) { - if (!confirm(this.issueMoveConfirmMsg)) { - return false; - } - } return this.resetAutosave(); }; @@ -113,48 +103,6 @@ import ZenMode from './zen_mode'; return this.titleField.val("WIP: " + (this.titleField.val())); }; - IssuableForm.prototype.initMoveDropdown = function() { - var $moveDropdown, pageSize; - $moveDropdown = $('.js-move-dropdown'); - if ($moveDropdown.length) { - pageSize = $moveDropdown.data('page-size'); - return $('.js-move-dropdown').select2({ - ajax: { - url: $moveDropdown.data('projects-url'), - quietMillis: 125, - data: function(term, page, context) { - return { - search: term, - offset_id: context - }; - }, - results: function(data) { - var context, - more; - - if (data.length >= pageSize) - more = true; - - if (data[data.length - 1]) - context = data[data.length - 1].id; - - return { - results: data, - more: more, - context: context - }; - } - }, - formatResult: function(project) { - return project.name_with_namespace; - }, - formatSelection: function(project) { - return project.name_with_namespace; - } - }); - } - }; - return IssuableForm; })(); }).call(window); diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 2bee4fb045a..7c4f4da6127 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -42,7 +42,7 @@ class Issue { initIssueBtnEventListeners() { const issueFailMessage = 'Unable to update this issue at this time.'; - return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => { + return $(document).on('click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', (e) => { var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); @@ -66,12 +66,11 @@ class Issue { const projectIssuesCounter = $('.issue_counter'); if ('id' in data) { - $(document).trigger('issuable:change'); - const isClosed = $button.hasClass('btn-close'); isClosedBadge.toggleClass('hidden', !isClosed); isOpenBadge.toggleClass('hidden', isClosed); + $(document).trigger('issuable:change', isClosed); this.toggleCloseReopenButton(isClosed); let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); @@ -121,7 +120,7 @@ class Issue { static submitNoteForm(form) { var noteText; noteText = form.find("textarea.js-note-text").val(); - if (noteText.trim().length > 0) { + if (noteText && noteText.trim().length > 0) { return form.submit(); } } diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index efae112923d..e115ee40219 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -17,10 +17,6 @@ export default { required: true, type: String, }, - canMove: { - required: true, - type: Boolean, - }, canUpdate: { required: true, type: Boolean, @@ -80,11 +76,11 @@ export default { type: Boolean, required: true, }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -96,10 +92,6 @@ export default { type: String, required: true, }, - projectsAutocompleteUrl: { - type: String, - required: true, - }, }, data() { const store = new Store({ @@ -142,7 +134,6 @@ export default { confidential: this.isConfidential, description: this.state.descriptionText, lockedWarningVisible: false, - move_to_project_id: 0, updateLoading: false, }); } @@ -151,16 +142,6 @@ export default { this.showForm = false; }, updateIssuable() { - const canPostUpdate = this.store.formState.move_to_project_id !== 0 ? - confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert - - if (!canPostUpdate) { - this.store.setFormState({ - updateLoading: false, - }); - return; - } - this.service.updateIssuable(this.store.formState) .then(res => res.json()) .then((data) => { @@ -239,14 +220,12 @@ export default { <form-component v-if="canUpdate && showForm" :form-state="formState" - :can-move="canMove" :can-destroy="canDestroy" :issuable-templates="issuableTemplates" - :markdown-docs="markdownDocs" - :markdown-preview-url="markdownPreviewUrl" + :markdown-docs-path="markdownDocsPath" + :markdown-preview-path="markdownPreviewPath" :project-path="projectPath" :project-namespace="projectNamespace" - :projects-autocomplete-url="projectsAutocompleteUrl" /> <div v-else> <title-component diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue index d59e6d11032..992b7064c13 100644 --- a/app/assets/javascripts/issue_show/components/edited.vue +++ b/app/assets/javascripts/issue_show/components/edited.vue @@ -37,7 +37,7 @@ export default { Edited <time-ago-tooltip v-if="updatedAt" - placement="bottom" + tooltip-placement="bottom" :time="updatedAt" /> <span diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 27b1b814f9a..dc902eefc5f 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -10,11 +10,11 @@ type: Object, required: true, }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -36,8 +36,8 @@ Description </label> <markdown-field - :markdown-preview-url="markdownPreviewUrl" - :markdown-docs="markdownDocs"> + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath"> <textarea id="issue-description" class="note-textarea js-gfm-input js-autosize markdown-area" diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue deleted file mode 100644 index 7bf2be8b28a..00000000000 --- a/app/assets/javascripts/issue_show/components/fields/project_move.vue +++ /dev/null @@ -1,83 +0,0 @@ -<script> - import tooltip from '../../../vue_shared/directives/tooltip'; - - export default { - directives: { - tooltip, - }, - props: { - formState: { - type: Object, - required: true, - }, - projectsAutocompleteUrl: { - type: String, - required: true, - }, - }, - mounted() { - const $moveDropdown = $(this.$refs['move-dropdown']); - - $moveDropdown.select2({ - ajax: { - url: this.projectsAutocompleteUrl, - quietMillis: 125, - data(term, page, context) { - return { - search: term, - offset_id: context, - }; - }, - results(data) { - const more = data.length >= 50; - const context = data[data.length - 1] ? data[data.length - 1].id : null; - - return { - results: data, - more, - context, - }; - }, - }, - formatResult(project) { - return project.name_with_namespace; - }, - formatSelection(project) { - return project.name_with_namespace; - }, - }) - .on('change', (e) => { - this.formState.move_to_project_id = parseInt(e.target.value, 10); - }); - }, - beforeDestroy() { - $(this.$refs['move-dropdown']).select2('destroy'); - }, - }; -</script> - -<template> - <fieldset> - <label - for="issuable-move" - class="sr-only"> - Move - </label> - <div class="issuable-form-select-holder append-right-5"> - <input - ref="move-dropdown" - type="hidden" - id="issuable-move" - data-placeholder="Move to a different project" /> - </div> - <span - v-tooltip - data-placement="auto top" - title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."> - <i - class="fa fa-question-circle" - aria-hidden="true"> - </i> - </span> - </fieldset> -</template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 76ec3dc9a5d..6a2dd502fe2 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -4,15 +4,10 @@ import descriptionField from './fields/description.vue'; import editActions from './edit_actions.vue'; import descriptionTemplate from './fields/description_template.vue'; - import projectMove from './fields/project_move.vue'; import confidentialCheckbox from './fields/confidential_checkbox.vue'; export default { props: { - canMove: { - type: Boolean, - required: true, - }, canDestroy: { type: Boolean, required: true, @@ -26,11 +21,11 @@ required: false, default: () => [], }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -42,10 +37,6 @@ type: String, required: true, }, - projectsAutocompleteUrl: { - type: String, - required: true, - }, }, components: { lockedWarning, @@ -53,7 +44,6 @@ descriptionField, descriptionTemplate, editActions, - projectMove, confidentialCheckbox, }, computed: { @@ -89,14 +79,10 @@ </div> <description-field :form-state="formState" - :markdown-preview-url="markdownPreviewUrl" - :markdown-docs="markdownDocs" /> + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" /> <confidential-checkbox :form-state="formState" /> - <project-move - v-if="canMove" - :form-state="formState" - :projects-autocomplete-url="projectsAutocompleteUrl" /> <edit-actions :form-state="formState" :can-destroy="canDestroy" /> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index ad8cb6465e2..8053ef57e6c 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -28,7 +28,6 @@ document.addEventListener('DOMContentLoaded', () => { props: { canUpdate: this.canUpdate, canDestroy: this.canDestroy, - canMove: this.canMove, endpoint: this.endpoint, issuableRef: this.issuableRef, initialTitleHtml: this.initialTitleHtml, @@ -37,11 +36,10 @@ document.addEventListener('DOMContentLoaded', () => { initialDescriptionText: this.initialDescriptionText, issuableTemplates: this.issuableTemplates, isConfidential: this.isConfidential, - markdownPreviewUrl: this.markdownPreviewUrl, - markdownDocs: this.markdownDocs, + markdownPreviewPath: this.markdownPreviewPath, + markdownDocsPath: this.markdownDocsPath, projectPath: this.projectPath, projectNamespace: this.projectNamespace, - projectsAutocompleteUrl: this.projectsAutocompleteUrl, updatedAt: this.updatedAt, updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 0c8bd6f1cc3..f4639e9ed2a 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -6,7 +6,6 @@ export default class Store { confidential: false, description: '', lockedWarningVisible: false, - move_to_project_id: 0, updateLoading: false, }; } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index b8f4f4eaba3..b8bebe1894f 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -27,6 +27,13 @@ } }; + w.gl.utils.isInIssuePage = () => { + const page = gl.utils.getPagePath(1); + const action = gl.utils.getPagePath(2); + + return page === 'issues' && action === 'show'; + }; + w.gl.utils.ajaxGet = function(url) { return $.ajax({ type: "GET", @@ -167,11 +174,12 @@ }; gl.utils.scrollToElement = function($el) { - var top = $el.offset().top; - gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height(); + const top = $el.offset().top; + const mrTabsHeight = $('.merge-request-tabs').height() || 0; + const headerHeight = $('.navbar-gitlab').height() || 0; return $('body, html').animate({ - scrollTop: top - (gl.mrTabsHeight) + scrollTop: top - mrTabsHeight - headerHeight, }, 200); }; diff --git a/app/assets/javascripts/lib/utils/pretty_time.js b/app/assets/javascripts/lib/utils/pretty_time.js index 716aefbfcb7..227bf65b560 100644 --- a/app/assets/javascripts/lib/utils/pretty_time.js +++ b/app/assets/javascripts/lib/utils/pretty_time.js @@ -2,19 +2,20 @@ import _ from 'underscore'; (() => { /* - * TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints, - * stringifyTime condensed or non-condensed, abbreviateTimelengths) + * TODO: Make these methods more configurable (e.g. stringifyTime condensed or + * non-condensed, abbreviateTimelengths) * */ const utils = window.gl.utils = gl.utils || {}; const prettyTime = utils.prettyTime = { /* * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # } - * Seconds can be negative or positive, zero or non-zero. + * Seconds can be negative or positive, zero or non-zero. Can be configured for any day + * or week length. */ - parseSeconds(seconds) { - const DAYS_PER_WEEK = 5; - const HOURS_PER_DAY = 8; + parseSeconds(seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) { + const DAYS_PER_WEEK = daysPerWeek; + const HOURS_PER_DAY = hoursPerDay; const MINUTES_PER_HOUR = 60; const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; diff --git a/app/assets/javascripts/lib/utils/sticky.js b/app/assets/javascripts/lib/utils/sticky.js index ff2b66046b4..283c0ec0410 100644 --- a/app/assets/javascripts/lib/utils/sticky.js +++ b/app/assets/javascripts/lib/utils/sticky.js @@ -1,5 +1,5 @@ export const isSticky = (el, scrollY, stickyTop) => { - const top = el.offsetTop - scrollY; + const top = Math.floor(el.offsetTop - scrollY); if (top <= stickyTop) { el.classList.add('is-stuck'); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 6d7c7e3c930..f14458c8d41 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -102,6 +102,7 @@ import './label_manager'; import './labels'; import './labels_select'; import './layout_nav'; +import './feature_highlight/feature_highlight_options'; import LazyLoader from './lazy_loader'; import './line_highlighter'; import './logo'; @@ -131,6 +132,7 @@ import './project_new'; import './project_select'; import './project_show'; import './project_variables'; +import './projects_dropdown'; import './projects_list'; import './syntax_highlight'; import './render_math'; @@ -248,7 +250,10 @@ $(function () { // Initialize popovers $body.popover({ selector: '[data-toggle="popover"]', - trigger: 'focus' + trigger: 'focus', + // set the viewport to the main content, excluding the navigation bar, so + // the navigation can't overlap the popover + viewport: '.page-with-sidebar' }); $('.trigger-submit').on('change', function () { return $(this).parents('form').submit(); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 5a9b3d19f84..3b3620fe61b 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -253,6 +253,7 @@ import bp from './breakpoints'; loadDiff(source) { if (this.diffsLoaded) { + document.dispatchEvent(new CustomEvent('scroll')); return; } diff --git a/app/assets/javascripts/monitoring/components/monitoring.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index a6a2d3119e3..74244faa5d9 100644 --- a/app/assets/javascripts/monitoring/components/monitoring.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -3,8 +3,9 @@ import _ from 'underscore'; import statusCodes from '../../lib/utils/http_status'; import MonitoringService from '../services/monitoring_service'; - import monitoringRow from './monitoring_row.vue'; - import monitoringState from './monitoring_state.vue'; + import GraphGroup from './graph_group.vue'; + import GraphRow from './graph_row.vue'; + import EmptyState from './empty_state.vue'; import MonitoringStore from '../stores/monitoring_store'; import eventHub from '../event_hub'; @@ -31,8 +32,9 @@ }, components: { - monitoringRow, - monitoringState, + GraphGroup, + GraphRow, + EmptyState, }, methods: { @@ -94,7 +96,6 @@ this.updatedAspectRatios = 0; } }, - }, created() { @@ -118,40 +119,27 @@ }, }; </script> + <template> - <div - class="prometheus-graphs" - v-if="!showEmptyState"> - <div - class="row" + <div v-if="!showEmptyState" class="prometheus-graphs"> + <graph-group v-for="(groupData, index) in store.groups" - :key="index"> - <div - class="col-md-12"> - <div - class="panel panel-default prometheus-panel"> - <div - class="panel-heading"> - <h4>{{groupData.group}}</h4> - </div> - <div - class="panel-body"> - <monitoring-row - v-for="(row, index) in groupData.metrics" - :key="index" - :row-data="row" - :update-aspect-ratio="updateAspectRatio" - :deployment-data="store.deploymentData" - /> - </div> - </div> - </div> - </div> + :key="index" + :name="groupData.group" + > + <graph-row + v-for="(row, index) in groupData.metrics" + :key="index" + :row-data="row" + :update-aspect-ratio="updateAspectRatio" + :deployment-data="store.deploymentData" + /> + </graph-group> </div> - <monitoring-state + <empty-state + v-else :selected-state="state" :documentation-path="documentationPath" :settings-path="settingsPath" - v-else /> </template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 598021aa4df..a8708be76de 100644 --- a/app/assets/javascripts/monitoring/components/monitoring_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -62,49 +62,33 @@ }, }; </script> + <template> - <div - class="prometheus-state"> - <div - class="row"> - <div - class="col-md-4 col-md-offset-4 state-svg" - v-html="currentState.svg"> - </div> + <div class="prometheus-state"> + <div class="row"> + <div class="col-md-4 col-md-offset-4 state-svg" v-html="currentState.svg"></div> </div> - <div - class="row"> - <div - class="col-md-6 col-md-offset-3"> - <h4 - class="text-center state-title"> + <div class="row"> + <div class="col-md-6 col-md-offset-3"> + <h4 class="text-center state-title"> {{currentState.title}} </h4> </div> </div> - <div - class="row"> - <div - class="col-md-6 col-md-offset-3"> - <div - class="description-text text-center state-description"> - {{currentState.description}} - <a - :href="settingsPath" - v-if="showButtonDescription"> - Prometheus server - </a> + <div class="row"> + <div class="col-md-6 col-md-offset-3"> + <div class="description-text text-center state-description"> + {{currentState.description}} + <a v-if="showButtonDescription" :href="settingsPath"> + Prometheus server + </a> </div> </div> </div> - <div - class="row state-button-section"> - <div - class="col-md-4 col-md-offset-4 text-center state-button"> - <a - class="btn btn-success" - :href="buttonPath"> - {{currentState.buttonText}} + <div class="row state-button-section"> + <div class="col-md-4 col-md-offset-4 text-center state-button"> + <a class="btn btn-success" :href="buttonPath"> + {{currentState.buttonText}} </a> </div> </div> diff --git a/app/assets/javascripts/monitoring/components/monitoring_column.vue b/app/assets/javascripts/monitoring/components/graph.vue index 407af51cb7a..9c785f4ada8 100644 --- a/app/assets/javascripts/monitoring/components/monitoring_column.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -1,19 +1,21 @@ <script> import d3 from 'd3'; - import monitoringLegends from './monitoring_legends.vue'; - import monitoringFlag from './monitoring_flag.vue'; - import monitoringDeployment from './monitoring_deployment.vue'; + import GraphLegend from './graph/legend.vue'; + import GraphFlag from './graph/flag.vue'; + import GraphDeployment from './graph/deployment.vue'; + import monitoringPaths from './monitoring_paths.vue'; import MonitoringMixin from '../mixins/monitoring_mixins'; import eventHub from '../event_hub'; import measurements from '../utils/measurements'; - import { formatRelevantDigits } from '../../lib/utils/number_utils'; + import { timeScaleFormat } from '../utils/date_time_formatters'; + import createTimeSeries from '../utils/multiple_time_series'; import bp from '../../breakpoints'; const bisectDate = d3.bisector(d => d.time).left; export default { props: { - columnData: { + graphData: { type: Object, required: true, }, @@ -35,49 +37,47 @@ data() { return { + baseGraphHeight: 450, + baseGraphWidth: 600, graphHeight: 450, graphWidth: 600, graphHeightOffset: 120, - xScale: {}, - yScale: {}, margin: {}, - data: [], unitOfDisplay: '', areaColorRgb: '#8fbce8', lineColorRgb: '#1f78d1', yAxisLabel: '', legendTitle: '', reducedDeploymentData: [], - area: '', - line: '', measurements: measurements.large, currentData: { time: new Date(), value: 0, }, - currentYCoordinate: 0, + currentDataIndex: 0, currentXCoordinate: 0, currentFlagPosition: 0, - metricUsage: '', showFlag: false, showDeployInfo: true, + timeSeries: [], }; }, components: { - monitoringLegends, - monitoringFlag, - monitoringDeployment, + GraphLegend, + GraphFlag, + GraphDeployment, + monitoringPaths, }, computed: { outterViewBox() { - return `0 0 ${this.graphWidth} ${this.graphHeight}`; + return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; }, innerViewBox() { - if ((this.graphWidth - 150) > 0) { - return `0 0 ${this.graphWidth - 150} ${this.graphHeight}`; + if ((this.baseGraphWidth - 150) > 0) { + return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; } return '0 0 0 0'; }, @@ -88,7 +88,7 @@ paddingBottomRootSvg() { return { - paddingBottom: `${(Math.ceil(this.graphHeight * 100) / this.graphWidth) || 0}%`, + paddingBottom: `${(Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth) || 0}%`, }; }, }, @@ -96,24 +96,23 @@ methods: { draw() { const breakpointSize = bp.getBreakpointSize(); - const query = this.columnData.queries[0]; + const query = this.graphData.queries[0]; this.margin = measurements.large.margin; if (breakpointSize === 'xs' || breakpointSize === 'sm') { this.graphHeight = 300; this.margin = measurements.small.margin; this.measurements = measurements.small; } - this.data = query.result[0].values; this.unitOfDisplay = query.unit || ''; - this.yAxisLabel = this.columnData.y_label || 'Values'; + this.yAxisLabel = this.graphData.y_label || 'Values'; this.legendTitle = query.label || 'Average'; this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - if (this.data !== undefined) { - this.renderAxesPaths(); - this.formatDeployments(); - } + this.baseGraphHeight = this.graphHeight; + this.baseGraphWidth = this.graphWidth; + this.renderAxesPaths(); + this.formatDeployments(); }, handleMouseOverGraph(e) { @@ -122,16 +121,17 @@ point.y = e.clientY; point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); point.x = point.x += 7; - const timeValueOverlay = this.xScale.invert(point.x); - const overlayIndex = bisectDate(this.data, timeValueOverlay, 1); - const d0 = this.data[overlayIndex - 1]; - const d1 = this.data[overlayIndex]; + const firstTimeSeries = this.timeSeries[0]; + const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); + const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); + const d0 = firstTimeSeries.values[overlayIndex - 1]; + const d1 = firstTimeSeries.values[overlayIndex]; if (d0 === undefined || d1 === undefined) return; const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; this.currentData = evalTime ? d1 : d0; - this.currentXCoordinate = Math.floor(this.xScale(this.currentData.time)); + this.currentDataIndex = evalTime ? overlayIndex : (overlayIndex - 1); + this.currentXCoordinate = Math.floor(firstTimeSeries.timeSeriesScaleX(this.currentData.time)); const currentDeployXPos = this.mouseOverDeployInfo(point.x); - this.currentYCoordinate = this.yScale(this.currentData.value); if (this.currentXCoordinate > (this.graphWidth - 200)) { this.currentFlagPosition = this.currentXCoordinate - 103; @@ -144,25 +144,34 @@ } else { this.showFlag = true; } - - this.metricUsage = `${formatRelevantDigits(this.currentData.value)} ${this.unitOfDisplay}`; }, renderAxesPaths() { + this.timeSeries = createTimeSeries(this.graphData.queries[0].result, + this.graphWidth, + this.graphHeight, + this.graphHeightOffset); + + if (this.timeSeries.length > 3) { + this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; + } + const axisXScale = d3.time.scale() .range([0, this.graphWidth]); - this.yScale = d3.scale.linear() + const axisYScale = d3.scale.linear() .range([this.graphHeight - this.graphHeightOffset, 0]); - axisXScale.domain(d3.extent(this.data, d => d.time)); - this.yScale.domain([0, d3.max(this.data.map(d => d.value))]); + + axisXScale.domain(d3.extent(this.timeSeries[0].values, d => d.time)); + axisYScale.domain([0, d3.max(this.timeSeries[0].values.map(d => d.value))]); const xAxis = d3.svg.axis() .scale(axisXScale) .ticks(measurements.xTicks) + .tickFormat(timeScaleFormat) .orient('bottom'); const yAxis = d3.svg.axis() - .scale(this.yScale) + .scale(axisYScale) .ticks(measurements.yTicks) .orient('left'); @@ -178,25 +187,6 @@ .attr('class', 'axis-tick'); } // Avoid adding the class to the first tick, to prevent coloring }); // This will select all of the ticks once they're rendered - - this.xScale = d3.time.scale() - .range([0, this.graphWidth - 70]); - - this.xScale.domain(d3.extent(this.data, d => d.time)); - - const areaFunction = d3.svg.area() - .x(d => this.xScale(d.time)) - .y0(this.graphHeight - this.graphHeightOffset) - .y1(d => this.yScale(d.value)) - .interpolate('linear'); - - const lineFunction = d3.svg.line() - .x(d => this.xScale(d.time)) - .y(d => this.yScale(d.value)); - - this.line = lineFunction(this.data); - - this.area = areaFunction(this.data); }, }, @@ -222,7 +212,7 @@ :class="classType"> <h5 class="text-center graph-title"> - {{columnData.title}} + {{graphData.title}} </h5> <div class="prometheus-svg-container" @@ -238,57 +228,51 @@ class="y-axis" transform="translate(70, 20)"> </g> - <monitoring-legends + <graph-legend :graph-width="graphWidth" :graph-height="graphHeight" :margin="margin" :measurements="measurements" - :area-color-rgb="areaColorRgb" :legend-title="legendTitle" :y-axis-label="yAxisLabel" - :metric-usage="metricUsage" + :time-series="timeSeries" + :unit-of-display="unitOfDisplay" + :current-data-index="currentDataIndex" /> <svg class="graph-data" :viewBox="innerViewBox" ref="graphData"> - <path - class="metric-area" - :d="area" - :fill="areaColorRgb" - transform="translate(-5, 20)"> - </path> - <path - class="metric-line" - :d="line" - :stroke="lineColorRgb" - fill="none" - stroke-width="2" - transform="translate(-5, 20)"> - </path> - <rect - class="prometheus-graph-overlay" - :width="(graphWidth - 70)" - :height="(graphHeight - 100)" - transform="translate(-5, 20)" - ref="graphOverlay" - @mousemove="handleMouseOverGraph($event)"> - </rect> + <monitoring-paths + v-for="(path, index) in timeSeries" + :key="index" + :generated-line-path="path.linePath" + :generated-area-path="path.areaPath" + :line-color="path.lineColor" + :area-color="path.areaColor" + /> <monitoring-deployment :show-deploy-info="showDeployInfo" :deployment-data="reducedDeploymentData" :graph-height="graphHeight" :graph-height-offset="graphHeightOffset" /> - <monitoring-flag + <graph-flag v-if="showFlag" :current-x-coordinate="currentXCoordinate" - :current-y-coordinate="currentYCoordinate" :current-data="currentData" :current-flag-position="currentFlagPosition" :graph-height="graphHeight" :graph-height-offset="graphHeightOffset" /> + <rect + class="prometheus-graph-overlay" + :width="(graphWidth - 70)" + :height="(graphHeight - 100)" + transform="translate(-5, 20)" + ref="graphOverlay" + @mousemove="handleMouseOverGraph($event)"> + </rect> </svg> </svg> </div> diff --git a/app/assets/javascripts/monitoring/components/monitoring_deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue index e6432ba3191..3623d2ed946 100644 --- a/app/assets/javascripts/monitoring/components/monitoring_deployment.vue +++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue @@ -1,8 +1,5 @@ <script> - import { - dateFormat, - timeFormat, - } from '../constants'; + import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; export default { props: { @@ -58,7 +55,7 @@ class="deploy-info" v-if="showDeployInfo"> <g - v-for="(deployment, index) in deploymentData" + v-for="(deployment, index) in deploymentData" :key="index" :class="nameDeploymentClass(deployment)" :transform="transformDeploymentGroup(deployment)"> @@ -92,7 +89,7 @@ width="90" height="58"> </rect> - <g + <g transform="translate(5, 2)"> <text class="deploy-info-text text-metric-bold"> diff --git a/app/assets/javascripts/monitoring/components/monitoring_flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index 5a0e50fcab3..a98e3d06c18 100644 --- a/app/assets/javascripts/monitoring/components/monitoring_flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -1,8 +1,5 @@ <script> - import { - dateFormat, - timeFormat, - } from '../constants'; + import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; export default { props: { @@ -10,10 +7,6 @@ type: Number, required: true, }, - currentYCoordinate: { - type: Number, - required: true, - }, currentFlagPosition: { type: Number, required: true, @@ -63,15 +56,6 @@ :y2="calculatedHeight" transform="translate(-5, 20)"> </line> - <circle - class="circle-metric" - :fill="circleColorRgb" - stroke="#000" - :cx="currentXCoordinate" - :cy="currentYCoordinate" - r="5" - transform="translate(-5, 20)"> - </circle> <svg class="rect-text-metric" :x="currentFlagPosition" diff --git a/app/assets/javascripts/monitoring/components/monitoring_legends.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index 922a5e1bf0e..a43dad8e601 100644 --- a/app/assets/javascripts/monitoring/components/monitoring_legends.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -1,4 +1,6 @@ <script> + import { formatRelevantDigits } from '../../../lib/utils/number_utils'; + export default { props: { graphWidth: { @@ -17,10 +19,6 @@ type: Object, required: true, }, - areaColorRgb: { - type: String, - required: true, - }, legendTitle: { type: String, required: true, @@ -29,15 +27,25 @@ type: String, required: true, }, - metricUsage: { + timeSeries: { + type: Array, + required: true, + }, + unitOfDisplay: { type: String, required: true, }, + currentDataIndex: { + type: Number, + required: true, + }, }, data() { return { yLabelWidth: 0, yLabelHeight: 0, + seriesXPosition: 0, + metricUsageXPosition: 0, }; }, computed: { @@ -63,10 +71,28 @@ yPosition() { return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0; }, + + }, + methods: { + translateLegendGroup(index) { + return `translate(0, ${12 * (index)})`; + }, + + formatMetricUsage(series) { + return `${formatRelevantDigits(series.values[this.currentDataIndex].value)} ${this.unitOfDisplay}`; + }, }, mounted() { this.$nextTick(() => { const bbox = this.$refs.ylabel.getBBox(); + this.metricUsageXPosition = 0; + this.seriesXPosition = 0; + if (this.$refs.legendTitleSvg != null) { + this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width; + } + if (this.$refs.seriesTitleSvg != null) { + this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width; + } this.yLabelWidth = bbox.width + 10; // Added some padding this.yLabelHeight = bbox.height + 5; }); @@ -74,7 +100,7 @@ }; </script> <template> - <g + <g class="axis-label-container"> <line class="label-x-axis-line" @@ -100,7 +126,7 @@ :width="yLabelWidth" :height="yLabelHeight"> </rect> - <text + <text class="label-axis-text y-label-text" text-anchor="middle" :transform="textTransform" @@ -121,24 +147,33 @@ dy=".35em"> Time </text> - <rect - :fill="areaColorRgb" - :width="measurements.legends.width" - :height="measurements.legends.height" - x="20" - :y="graphHeight - measurements.legendOffset"> - </rect> - <text - class="text-metric-title" - x="50" - :y="graphHeight - 25"> - {{legendTitle}} - </text> - <text - class="text-metric-usage" - x="50" - :y="graphHeight - 10"> - {{metricUsage}} - </text> + <g class="legend-group" + v-for="(series, index) in timeSeries" + :key="index" + :transform="translateLegendGroup(index)"> + <rect + :fill="series.areaColor" + :width="measurements.legends.width" + :height="measurements.legends.height" + x="20" + :y="graphHeight - measurements.legendOffset"> + </rect> + <text + v-if="timeSeries.length > 1" + class="legend-metric-title" + ref="legendTitleSvg" + x="38" + :y="graphHeight - 30"> + {{legendTitle}} Series {{index + 1}} {{formatMetricUsage(series)}} + </text> + <text + v-else + class="legend-metric-title" + ref="legendTitleSvg" + x="38" + :y="graphHeight - 30"> + {{legendTitle}} {{formatMetricUsage(series)}} + </text> + </g> </g> </template> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue new file mode 100644 index 00000000000..32c90fda8cc --- /dev/null +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -0,0 +1,21 @@ +<script> +export default { + props: { + name: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="panel panel-default prometheus-panel"> + <div class="panel-heading"> + <h4>{{name}}</h4> + </div> + <div class="panel-body"> + <slot /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_row.vue b/app/assets/javascripts/monitoring/components/graph_row.vue index e5528f17880..bdb9149c3b4 100644 --- a/app/assets/javascripts/monitoring/components/monitoring_row.vue +++ b/app/assets/javascripts/monitoring/components/graph_row.vue @@ -1,5 +1,5 @@ <script> - import monitoringColumn from './monitoring_column.vue'; + import Graph from './graph.vue'; export default { props: { @@ -17,7 +17,7 @@ }, }, components: { - monitoringColumn, + Graph, }, computed: { bootstrapClass() { @@ -26,12 +26,12 @@ }, }; </script> + <template> - <div - class="prometheus-row row"> - <monitoring-column - v-for="(column, index) in rowData" - :column-data="column" + <div class="prometheus-row row"> + <graph + v-for="(graphData, index) in rowData" + :graph-data="graphData" :class-type="bootstrapClass" :key="index" :update-aspect-ratio="updateAspectRatio" diff --git a/app/assets/javascripts/monitoring/components/monitoring_paths.vue b/app/assets/javascripts/monitoring/components/monitoring_paths.vue new file mode 100644 index 00000000000..043f1bf66bb --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_paths.vue @@ -0,0 +1,40 @@ +<script> + export default { + props: { + generatedLinePath: { + type: String, + required: true, + }, + generatedAreaPath: { + type: String, + required: true, + }, + lineColor: { + type: String, + required: true, + }, + areaColor: { + type: String, + required: true, + }, + }, + }; +</script> +<template> + <g> + <path + class="metric-area" + :d="generatedAreaPath" + :fill="areaColor" + transform="translate(-5, 20)"> + </path> + <path + class="metric-line" + :d="generatedLinePath" + :stroke="lineColor" + fill="none" + stroke-width="1" + transform="translate(-5, 20)"> + </path> + </g> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js deleted file mode 100644 index c3a8da52404..00000000000 --- a/app/assets/javascripts/monitoring/constants.js +++ /dev/null @@ -1,4 +0,0 @@ -import d3 from 'd3'; - -export const dateFormat = d3.time.format('%b %d, %Y'); -export const timeFormat = d3.time.format('%H:%M%p'); diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js index 8e62fa63f13..345a0b37a76 100644 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js @@ -21,9 +21,9 @@ const mixins = { formatDeployments() { this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { const time = new Date(deployment.created_at); - const xPos = Math.floor(this.xScale(time)); + const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time)); - time.setSeconds(this.data[0].time.getSeconds()); + time.setSeconds(this.timeSeries[0].values[0].time.getSeconds()); if (xPos >= 0) { deploymentDataArray.push({ diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 5d5cb56af72..ef280e02092 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import Monitoring from './components/monitoring.vue'; +import Dashboard from './components/dashboard.vue'; document.addEventListener('DOMContentLoaded', () => new Vue({ el: '#prometheus-graphs', components: { - 'monitoring-dashboard': Monitoring, + Dashboard, }, - render: createElement => createElement('monitoring-dashboard'), + render: createElement => createElement('dashboard'), })); diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 737c964f12e..0a4cdd88044 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -1,46 +1,52 @@ import _ from 'underscore'; -class MonitoringStore { +function sortMetrics(metrics) { + return _.chain(metrics).sortBy('weight').sortBy('title').value(); +} + +function normalizeMetrics(metrics) { + return metrics.map(metric => ({ + ...metric, + queries: metric.queries.map(query => ({ + ...query, + result: query.result.map(result => ({ + ...result, + values: result.values.map(([timestamp, value]) => ({ + time: new Date(timestamp * 1000), + value, + })), + })), + })), + })); +} + +function collate(array, rows = 2) { + const collatedArray = []; + let row = []; + array.forEach((value, index) => { + row.push(value); + if ((index + 1) % rows === 0) { + collatedArray.push(row); + row = []; + } + }); + if (row.length > 0) { + collatedArray.push(row); + } + return collatedArray; +} + +export default class MonitoringStore { constructor() { this.groups = []; this.deploymentData = []; } - // eslint-disable-next-line class-methods-use-this - createArrayRows(metrics = []) { - const currentMetrics = metrics; - const availableMetrics = []; - let metricsRow = []; - let index = 1; - Object.keys(currentMetrics).forEach((key) => { - const metricValues = currentMetrics[key].queries[0].result[0].values; - if (metricValues != null) { - const literalMetrics = metricValues.map(metric => ({ - time: new Date(metric[0] * 1000), - value: metric[1], - })); - currentMetrics[key].queries[0].result[0].values = literalMetrics; - metricsRow.push(currentMetrics[key]); - if (index % 2 === 0) { - availableMetrics.push(metricsRow); - metricsRow = []; - } - index = index += 1; - } - }); - if (metricsRow.length > 0) { - availableMetrics.push(metricsRow); - } - return availableMetrics; - } - storeMetrics(groups = []) { - this.groups = groups.map((group) => { - const currentGroup = group; - currentGroup.metrics = _.chain(group.metrics).sortBy('weight').sortBy('title').value(); - currentGroup.metrics = this.createArrayRows(currentGroup.metrics); - return currentGroup; - }); + this.groups = groups.map(group => ({ + ...group, + metrics: collate(normalizeMetrics(sortMetrics(group.metrics))), + })); } storeDeploymentData(deploymentData = []) { @@ -57,5 +63,3 @@ class MonitoringStore { return metricsCount; } } - -export default MonitoringStore; diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js new file mode 100644 index 00000000000..26bcaa02511 --- /dev/null +++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js @@ -0,0 +1,15 @@ +import d3 from 'd3'; + +export const dateFormat = d3.time.format('%b %-d, %Y'); +export const timeFormat = d3.time.format('%-I:%M%p'); + +export const timeScaleFormat = d3.time.format.multi([ + ['.%L', d => d.getMilliseconds()], + [':%S', d => d.getSeconds()], + ['%-I:%M', d => d.getMinutes()], + ['%-I %p', d => d.getHours()], + ['%a %-d', d => d.getDay() && d.getDate() !== 1], + ['%b %-d', d => d.getDate() !== 1], + ['%B', d => d.getMonth()], + ['%Y', () => true], +]); diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js index 62cd19c86e1..ee3c45efacc 100644 --- a/app/assets/javascripts/monitoring/utils/measurements.js +++ b/app/assets/javascripts/monitoring/utils/measurements.js @@ -7,15 +7,15 @@ export default { left: 40, }, legends: { - width: 15, - height: 25, + width: 10, + height: 3, }, backgroundLegend: { width: 30, height: 50, }, axisLabelLineOffset: -20, - legendOffset: 35, + legendOffset: 33, }, large: { // This covers both md and lg screen sizes margin: { @@ -25,15 +25,15 @@ export default { left: 80, }, legends: { - width: 20, - height: 30, + width: 15, + height: 3, }, backgroundLegend: { width: 30, height: 150, }, axisLabelLineOffset: 20, - legendOffset: 38, + legendOffset: 36, }, xTicks: 8, yTicks: 3, diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js new file mode 100644 index 00000000000..05d551e917c --- /dev/null +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -0,0 +1,80 @@ +import d3 from 'd3'; +import _ from 'underscore'; + +export default function createTimeSeries(seriesData, graphWidth, graphHeight, graphHeightOffset) { + const maxValues = seriesData.map((timeSeries, index) => { + const maxValue = d3.max(timeSeries.values.map(d => d.value)); + return { + maxValue, + index, + }; + }); + + const maxValueFromSeries = _.max(maxValues, val => val.maxValue); + + let timeSeriesNumber = 1; + let lineColor = '#1f78d1'; + let areaColor = '#8fbce8'; + return seriesData.map((timeSeries) => { + const timeSeriesScaleX = d3.time.scale() + .range([0, graphWidth - 70]); + + const timeSeriesScaleY = d3.scale.linear() + .range([graphHeight - graphHeightOffset, 0]); + + timeSeriesScaleX.domain(d3.extent(timeSeries.values, d => d.time)); + timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]); + + const lineFunction = d3.svg.line() + .x(d => timeSeriesScaleX(d.time)) + .y(d => timeSeriesScaleY(d.value)); + + const areaFunction = d3.svg.area() + .x(d => timeSeriesScaleX(d.time)) + .y0(graphHeight - graphHeightOffset) + .y1(d => timeSeriesScaleY(d.value)) + .interpolate('linear'); + + switch (timeSeriesNumber) { + case 1: + lineColor = '#1f78d1'; + areaColor = '#8fbce8'; + break; + case 2: + lineColor = '#fc9403'; + areaColor = '#feca81'; + break; + case 3: + lineColor = '#db3b21'; + areaColor = '#ed9d90'; + break; + case 4: + lineColor = '#1aaa55'; + areaColor = '#8dd5aa'; + break; + case 5: + lineColor = '#6666c4'; + areaColor = '#d1d1f0'; + break; + default: + lineColor = '#1f78d1'; + areaColor = '#8fbce8'; + break; + } + + if (timeSeriesNumber <= 5) { + timeSeriesNumber = timeSeriesNumber += 1; + } else { + timeSeriesNumber = 1; + } + + return { + linePath: lineFunction(timeSeries.values), + areaPath: areaFunction(timeSeries.values), + timeSeriesScaleX, + values: timeSeries.values, + lineColor, + areaColor, + }; + }); +} diff --git a/app/assets/javascripts/new_sidebar.js b/app/assets/javascripts/new_sidebar.js index 2d1ed9e4076..05e3f33f5ed 100644 --- a/app/assets/javascripts/new_sidebar.js +++ b/app/assets/javascripts/new_sidebar.js @@ -15,6 +15,7 @@ export default class NewNavSidebar { this.$openSidebar = $('.toggle-mobile-nav'); this.$closeSidebar = $('.close-nav-button'); this.$sidebarToggle = $('.js-toggle-sidebar'); + this.$topLevelLinks = $('.sidebar-top-level-items > li > a'); } bindEvents() { @@ -47,10 +48,13 @@ export default class NewNavSidebar { if (this.$sidebar.length) { this.$sidebar.toggleClass('sidebar-icons-only', collapsed); - this.$page.toggleClass('page-with-new-sidebar', !collapsed); this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed); } NewNavSidebar.setCollapsedCookie(collapsed); + + this.$topLevelLinks.attr('title', function updateTopLevelTitle() { + return collapsed ? this.getAttribute('aria-label') : ''; + }); } render() { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b38a6abc8d1..a09270d6d24 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -464,7 +464,6 @@ export default class Notes { } renderDiscussionAvatar(diffAvatarContainer, noteEntity) { - var commentButton = diffAvatarContainer.find('.js-add-diff-note-button'); var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); if (!avatarHolder.length) { @@ -475,10 +474,6 @@ export default class Notes { gl.diffNotesCompileComponents(); } - - if (commentButton.length) { - commentButton.remove(); - } } /** @@ -767,6 +762,7 @@ export default class Notes { var $note, $notes; $note = $(el); $notes = $note.closest('.discussion-notes'); + const discussionId = $('.notes', $notes).data('discussion-id'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -783,6 +779,8 @@ export default class Notes { // "Discussions" tab $notes.closest('.timeline-entry').remove(); + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + // The notes tr can contain multiple lists of notes, like on the parallel diff if (notesTr.find('.discussion-notes').length > 1) { $notes.remove(); diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue new file mode 100644 index 00000000000..16f4e22aa9b --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -0,0 +1,347 @@ +<script> + /* global Flash, Autosave */ + import { mapActions, mapGetters } from 'vuex'; + import _ from 'underscore'; + import '../../autosave'; + import TaskList from '../../task_list'; + import * as constants from '../constants'; + import eventHub from '../event_hub'; + import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import markdownField from '../../vue_shared/components/markdown/field.vue'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + + export default { + name: 'issueCommentForm', + data() { + return { + note: '', + noteType: constants.COMMENT, + // Can't use mapGetters, + // this needs to be in the data object because it belongs to the state + issueState: this.$store.getters.getIssueData.state, + isSubmitting: false, + isSubmitButtonDisabled: true, + }; + }, + components: { + confidentialIssue, + issueNoteSignedOutWidget, + markdownField, + userAvatarLink, + }, + watch: { + note(newNote) { + this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); + }, + isSubmitting(newValue) { + this.setIsSubmitButtonDisabled(this.note, newValue); + }, + }, + computed: { + ...mapGetters([ + 'getCurrentUserLastNote', + 'getUserData', + 'getIssueData', + 'getNotesData', + ]), + isLoggedIn() { + return this.getUserData.id; + }, + commentButtonTitle() { + return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; + }, + isIssueOpen() { + return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; + }, + issueActionButtonTitle() { + if (this.note.length) { + const actionText = this.isIssueOpen ? 'close' : 'reopen'; + + return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`; + } + + return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; + }, + actionButtonClassNames() { + return { + 'btn-reopen': !this.isIssueOpen, + 'btn-close': this.isIssueOpen, + 'js-note-target-close': this.isIssueOpen, + 'js-note-target-reopen': !this.isIssueOpen, + }; + }, + markdownDocsPath() { + return this.getNotesData.markdownDocsPath; + }, + quickActionsDocsPath() { + return this.getNotesData.quickActionsDocsPath; + }, + markdownPreviewPath() { + return this.getIssueData.preview_note_path; + }, + author() { + return this.getUserData; + }, + canUpdateIssue() { + return this.getIssueData.current_user.can_update; + }, + endpoint() { + return this.getIssueData.create_note_path; + }, + isConfidentialIssue() { + return this.getIssueData.confidential; + }, + }, + methods: { + ...mapActions([ + 'saveNote', + 'removePlaceholderNotes', + ]), + setIsSubmitButtonDisabled(note, isSubmitting) { + if (!_.isEmpty(note) && !isSubmitting) { + this.isSubmitButtonDisabled = false; + } else { + this.isSubmitButtonDisabled = true; + } + }, + handleSave(withIssueAction) { + if (this.note.length) { + const noteData = { + endpoint: this.endpoint, + flashContainer: this.$el, + data: { + note: { + noteable_type: constants.NOTEABLE_TYPE, + noteable_id: this.getIssueData.id, + note: this.note, + }, + }, + }; + + if (this.noteType === constants.DISCUSSION) { + noteData.data.note.type = constants.DISCUSSION_NOTE; + } + this.isSubmitting = true; + this.note = ''; // Empty textarea while being requested. Repopulate in catch + + this.saveNote(noteData) + .then((res) => { + this.isSubmitting = false; + if (res.errors) { + if (res.errors.commands_only) { + this.discard(); + } else { + Flash( + 'Something went wrong while adding your comment. Please try again.', + 'alert', + $(this.$refs.commentForm), + ); + } + } else { + this.discard(); + } + + if (withIssueAction) { + this.toggleIssueState(); + } + }) + .catch(() => { + this.isSubmitting = false; + this.discard(false); + const msg = 'Your comment could not be submitted! Please check your network connection and try again.'; + Flash(msg, 'alert', $(this.$el)); + this.note = noteData.data.note.note; // Restore textarea content. + this.removePlaceholderNotes(); + }); + } else { + this.toggleIssueState(); + } + }, + toggleIssueState() { + this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED; + + // This is out of scope for the Notes Vue component. + // It was the shortest path to update the issue state and relevant places. + const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close'; + $(`.js-btn-issue-action.${btnClass}:visible`).trigger('click'); + }, + discard(shouldClear = true) { + // `blur` is needed to clear slash commands autocomplete cache if event fired. + // `focus` is needed to remain cursor in the textarea. + this.$refs.textarea.blur(); + this.$refs.textarea.focus(); + + if (shouldClear) { + this.note = ''; + } + + // reset autostave + this.autosave.reset(); + }, + setNoteType(type) { + this.noteType = type; + }, + editCurrentUserLastNote() { + if (this.note === '') { + const lastNote = this.getCurrentUserLastNote; + + if (lastNote) { + eventHub.$emit('enterEditMode', { + noteId: lastNote.id, + }); + } + } + }, + initAutoSave() { + if (this.isLoggedIn) { + this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue'); + } + }, + initTaskList() { + return new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); + }, + }, + mounted() { + // jQuery is needed here because it is a custom event being dispatched with jQuery. + $(document).on('issuable:change', (e, isClosed) => { + this.issueState = isClosed ? constants.CLOSED : constants.REOPENED; + }); + + this.initAutoSave(); + this.initTaskList(); + }, + }; +</script> + +<template> + <div> + <issue-note-signed-out-widget v-if="!isLoggedIn" /> + <ul + v-else + class="notes notes-form timeline"> + <li class="timeline-entry"> + <div class="timeline-entry-inner"> + <div class="flash-container error-alert timeline-content"></div> + <div class="timeline-icon hidden-xs hidden-sm"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content timeline-content-form"> + <form + ref="commentForm" + class="new-note js-quick-submit common-note-form gfm-form js-main-target-form"> + <confidentialIssue v-if="isConfidentialIssue" /> + <div class="error-alert"></div> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :add-spacing-classes="false" + :is-confidential-issue="isConfidentialIssue"> + <textarea + id="note-body" + name="note[note]" + class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea" + data-supports-quick-actions="true" + aria-label="Description" + v-model="note" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.up="editCurrentUserLastNote()" + @keydown.meta.enter="handleSave()"> + </textarea> + </markdown-field> + <div class="note-form-actions"> + <div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"> + <button + @click.prevent="handleSave()" + :disabled="isSubmitButtonDisabled" + class="btn btn-create comment-btn js-comment-button js-comment-submit-button" + type="submit"> + {{commentButtonTitle}} + </button> + <button + :disabled="isSubmitButtonDisabled" + name="button" + type="button" + class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle" + data-toggle="dropdown" + aria-label="Open comment type dropdown"> + <i + aria-hidden="true" + class="fa fa-caret-down toggle-icon"> + </i> + </button> + + <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> + <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('comment')"> + <i + aria-hidden="true" + class="fa fa-check icon"> + </i> + <div class="description"> + <strong>Comment</strong> + <p> + Add a general comment to this issue. + </p> + </div> + </button> + </li> + <li class="divider droplab-item-ignore"></li> + <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('discussion')"> + <i + aria-hidden="true" + class="fa fa-check icon"> + </i> + <div class="description"> + <strong>Start discussion</strong> + <p> + Discuss a specific suggestion or question. + </p> + </div> + </button> + </li> + </ul> + </div> + <button + type="button" + @click="handleSave(true)" + v-if="canUpdateIssue" + :class="actionButtonClassNames" + class="btn btn-comment btn-comment-and-close"> + {{issueActionButtonTitle}} + </button> + <button + type="button" + v-if="note.length" + @click="discard" + class="btn btn-cancel js-note-discard"> + Discard draft + </button> + </div> + </form> + </div> + </div> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue new file mode 100644 index 00000000000..b131ef4b182 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_discussion.vue @@ -0,0 +1,232 @@ +<script> + /* global Flash */ + import { mapActions, mapGetters } from 'vuex'; + import { SYSTEM_NOTE } from '../constants'; + import issueNote from './issue_note.vue'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import issueNoteHeader from './issue_note_header.vue'; + import issueNoteActions from './issue_note_actions.vue'; + import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import issueNoteEditedText from './issue_note_edited_text.vue'; + import issueNoteForm from './issue_note_form.vue'; + import placeholderNote from './issue_placeholder_note.vue'; + import placeholderSystemNote from './issue_placeholder_system_note.vue'; + import autosave from '../mixins/autosave'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + isReplying: false, + }; + }, + components: { + issueNote, + userAvatarLink, + issueNoteHeader, + issueNoteActions, + issueNoteSignedOutWidget, + issueNoteEditedText, + issueNoteForm, + placeholderNote, + placeholderSystemNote, + }, + mixins: [ + autosave, + ], + computed: { + ...mapGetters([ + 'getIssueData', + ]), + discussion() { + return this.note.notes[0]; + }, + author() { + return this.discussion.author; + }, + canReply() { + return this.getIssueData.current_user.can_create_note; + }, + newNotePath() { + return this.getIssueData.create_note_path; + }, + lastUpdatedBy() { + const { notes } = this.note; + + if (notes.length > 1) { + return notes[notes.length - 1].author; + } + + return null; + }, + lastUpdatedAt() { + const { notes } = this.note; + + if (notes.length > 1) { + return notes[notes.length - 1].created_at; + } + + return null; + }, + }, + methods: { + ...mapActions([ + 'saveNote', + 'toggleDiscussion', + 'removePlaceholderNotes', + ]), + componentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === SYSTEM_NOTE) { + return placeholderSystemNote; + } + return placeholderNote; + } + + return issueNote; + }, + componentData(note) { + return note.isPlaceholderNote ? note.notes[0] : note; + }, + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.note.id }); + }, + showReplyForm() { + this.isReplying = true; + }, + cancelReplyForm(shouldConfirm) { + if (shouldConfirm && this.$refs.noteForm.isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel creating this comment?')) { + return; + } + } + + this.resetAutoSave(); + this.isReplying = false; + }, + saveReply(noteText, form, callback) { + const replyData = { + endpoint: this.newNotePath, + flashContainer: this.$el, + data: { + in_reply_to_discussion_id: this.note.reply_id, + target_type: 'issue', + target_id: this.discussion.noteable_id, + note: { note: noteText }, + }, + }; + this.isReplying = false; + + this.saveNote(replyData) + .then(() => { + this.resetAutoSave(); + callback(); + }) + .catch((err) => { + this.removePlaceholderNotes(); + this.isReplying = true; + this.$nextTick(() => { + const msg = 'Your comment could not be submitted! Please check your network connection and try again.'; + Flash(msg, 'alert', $(this.$el)); + this.$refs.noteForm.note = noteText; + callback(err); + }); + }); + }, + }, + mounted() { + if (this.isReplying) { + this.initAutoSave(); + } + }, + updated() { + if (this.isReplying) { + if (!this.autosave) { + this.initAutoSave(); + } else { + this.setAutoSave(); + } + } + }, + }; +</script> + +<template> + <li class="note note-discussion timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content"> + <div class="discussion"> + <div class="discussion-header"> + <issue-note-header + :author="author" + :created-at="discussion.created_at" + :note-id="discussion.id" + :include-toggle="true" + @toggleHandler="toggleDiscussionHandler" + action-text="started a discussion" + class="discussion" + /> + <issue-note-edited-text + v-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + </div> + <div + v-if="note.expanded" + class="discussion-body"> + <div class="panel panel-default"> + <div class="discussion-notes"> + <ul class="notes"> + <component + v-for="note in note.notes" + :is="componentName(note)" + :note="componentData(note)" + :key="note.id" + /> + </ul> + <div + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder"> + <button + v-if="canReply && !isReplying" + @click="showReplyForm" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + title="Add a reply">Reply...</button> + <issue-note-form + v-if="isReplying" + save-button-title="Comment" + :discussion="note" + :is-editing="false" + @handleFormUpdate="saveReply" + @cancelFormEdition="cancelReplyForm" + ref="noteForm" + /> + <issue-note-signed-out-widget v-if="!canReply" /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note.vue b/app/assets/javascripts/notes/components/issue_note.vue new file mode 100644 index 00000000000..3483f6c7538 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note.vue @@ -0,0 +1,186 @@ +<script> + /* global Flash */ + + import { mapGetters, mapActions } from 'vuex'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import issueNoteHeader from './issue_note_header.vue'; + import issueNoteActions from './issue_note_actions.vue'; + import issueNoteBody from './issue_note_body.vue'; + import eventHub from '../event_hub'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + isEditing: false, + isDeleting: false, + isRequesting: false, + }; + }, + components: { + userAvatarLink, + issueNoteHeader, + issueNoteActions, + issueNoteBody, + }, + computed: { + ...mapGetters([ + 'targetNoteHash', + 'getUserData', + ]), + author() { + return this.note.author; + }, + classNameBindings() { + return { + 'is-editing': this.isEditing && !this.isRequesting, + 'is-requesting being-posted': this.isRequesting, + 'disabled-content': this.isDeleting, + target: this.targetNoteHash === this.noteAnchorId, + }; + }, + canReportAsAbuse() { + return this.note.report_abuse_path && this.author.id !== this.getUserData.id; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + }, + methods: { + ...mapActions([ + 'deleteNote', + 'updateNote', + 'scrollToNoteIfNeeded', + ]), + editHandler() { + this.isEditing = true; + }, + deleteHandler() { + // eslint-disable-next-line no-alert + if (confirm('Are you sure you want to delete this list?')) { + this.isDeleting = true; + + this.deleteNote(this.note) + .then(() => { + this.isDeleting = false; + }) + .catch(() => { + Flash('Something went wrong while deleting your note. Please try again.'); + this.isDeleting = false; + }); + } + }, + formUpdateHandler(noteText, parentElement, callback) { + const data = { + endpoint: this.note.path, + note: { + target_type: 'issue', + target_id: this.note.noteable_id, + note: { note: noteText }, + }, + }; + this.isRequesting = true; + this.oldContent = this.note.note_html; + this.note.note_html = noteText; + + this.updateNote(data) + .then(() => { + this.isEditing = false; + this.isRequesting = false; + $(this.$refs.noteBody.$el).renderGFM(); + this.$refs.noteBody.resetAutoSave(); + callback(); + }) + .catch(() => { + this.isRequesting = false; + this.isEditing = true; + this.$nextTick(() => { + const msg = 'Something went wrong while editing your comment. Please try again.'; + Flash(msg, 'alert', $(this.$el)); + this.recoverNoteContent(noteText); + callback(); + }); + }); + }, + formCancelHandler(shouldConfirm, isDirty) { + if (shouldConfirm && isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel editing this comment?')) return; + } + this.$refs.noteBody.resetAutoSave(); + if (this.oldContent) { + this.note.note_html = this.oldContent; + this.oldContent = null; + } + this.isEditing = false; + }, + recoverNoteContent(noteText) { + // 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 = noteText; // TODO: This could be better + }, + }, + created() { + eventHub.$on('enterEditMode', ({ noteId }) => { + if (noteId === this.note.id) { + this.isEditing = true; + this.scrollToNoteIfNeeded($(this.$el)); + } + }); + }, + }; +</script> + +<template> + <li + class="note timeline-entry" + :id="noteAnchorId" + :class="classNameBindings" + :data-award-url="note.toggle_award_path"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content"> + <div class="note-header"> + <issue-note-header + :author="author" + :created-at="note.created_at" + :note-id="note.id" + action-text="commented" + /> + <issue-note-actions + :author-id="author.id" + :note-id="note.id" + :access-level="note.human_access" + :can-edit="note.current_user.can_edit" + :can-delete="note.current_user.can_edit" + :can-report-as-abuse="canReportAsAbuse" + :report-abuse-path="note.report_abuse_path" + @handleEdit="editHandler" + @handleDelete="deleteHandler" + /> + </div> + <issue-note-body + :note="note" + :can-edit="note.current_user.can_edit" + :is-editing="isEditing" + @handleFormUpdate="formUpdateHandler" + @cancelFormEdition="formCancelHandler" + ref="noteBody" + /> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_actions.vue b/app/assets/javascripts/notes/components/issue_note_actions.vue new file mode 100644 index 00000000000..60c172321d1 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_actions.vue @@ -0,0 +1,167 @@ +<script> + import { mapGetters } from 'vuex'; + import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; + import emojiSmile from 'icons/_emoji_smile.svg'; + import emojiSmiley from 'icons/_emoji_smiley.svg'; + import editSvg from 'icons/_icon_pencil.svg'; + import ellipsisSvg from 'icons/_ellipsis_v.svg'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + name: 'issueNoteActions', + props: { + authorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + accessLevel: { + type: String, + required: false, + default: '', + }, + reportAbusePath: { + type: String, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + canDelete: { + type: Boolean, + required: true, + }, + canReportAsAbuse: { + type: Boolean, + required: true, + }, + }, + directives: { + tooltip, + }, + components: { + loadingIcon, + }, + computed: { + ...mapGetters([ + 'getUserDataByProp', + ]), + shouldShowActionsDropdown() { + return this.currentUserId && (this.canEdit || this.canReportAsAbuse); + }, + canAddAwardEmoji() { + return this.currentUserId; + }, + isAuthoredByCurrentUser() { + return this.authorId === this.currentUserId; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + }, + methods: { + onEdit() { + this.$emit('handleEdit'); + }, + onDelete() { + this.$emit('handleDelete'); + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + this.editSvg = editSvg; + this.ellipsisSvg = ellipsisSvg; + }, + }; +</script> + +<template> + <div class="note-actions"> + <span + v-if="accessLevel" + class="note-role">{{accessLevel}}</span> + <div + v-if="canAddAwardEmoji" + class="note-actions-item"> + <a + v-tooltip + :class="{ 'js-user-authored': isAuthoredByCurrentUser }" + class="note-action-button note-emoji-button js-add-award js-note-emoji" + data-position="right" + data-placement="bottom" + data-container="body" + href="#" + title="Add reaction"> + <loading-icon :inline="true" /> + <span + v-html="emojiSmiling" + class="link-highlight award-control-icon-neutral"> + </span> + <span + v-html="emojiSmiley" + class="link-highlight award-control-icon-positive"> + </span> + <span + v-html="emojiSmile" + class="link-highlight award-control-icon-super-positive"> + </span> + </a> + </div> + <div + v-if="canEdit" + class="note-actions-item"> + <button + @click="onEdit" + v-tooltip + type="button" + title="Edit comment" + class="note-action-button js-note-edit btn btn-transparent" + data-container="body" + data-placement="bottom"> + <span + v-html="editSvg" + class="link-highlight"></span> + </button> + </div> + <div + v-if="shouldShowActionsDropdown" + class="dropdown more-actions note-actions-item"> + <button + v-tooltip + type="button" + title="More actions" + class="note-action-button more-actions-toggle btn btn-transparent" + data-toggle="dropdown" + data-container="body" + data-placement="bottom"> + <span + class="icon" + v-html="ellipsisSvg"></span> + </button> + <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> + <li v-if="canReportAsAbuse"> + <a :href="reportAbusePath"> + Report as abuse + </a> + </li> + <li v-if="canEdit"> + <button + @click.prevent="onDelete" + class="btn btn-transparent js-note-delete js-note-delete" + type="button"> + <span class="text-danger"> + Delete comment + </span> + </button> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_attachment.vue b/app/assets/javascripts/notes/components/issue_note_attachment.vue new file mode 100644 index 00000000000..7134a3eb47e --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_attachment.vue @@ -0,0 +1,37 @@ +<script> + export default { + name: 'issueNoteAttachment', + props: { + attachment: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <div class="note-attachment"> + <a + v-if="attachment.image" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer"> + <img + :src="attachment.url" + class="note-image-attach" /> + </a> + <div class="attachment"> + <a + v-if="attachment.url" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer"> + <i + class="fa fa-paperclip" + aria-hidden="true"></i> + {{attachment.filename}} + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_awards_list.vue b/app/assets/javascripts/notes/components/issue_note_awards_list.vue new file mode 100644 index 00000000000..d42e61e3899 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_awards_list.vue @@ -0,0 +1,228 @@ +<script> + /* global Flash */ + + import { mapActions, mapGetters } from 'vuex'; + import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; + import emojiSmile from 'icons/_emoji_smile.svg'; + import emojiSmiley from 'icons/_emoji_smiley.svg'; + import { glEmojiTag } from '../../emoji'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + props: { + awards: { + type: Array, + required: true, + }, + toggleAwardPath: { + type: String, + required: true, + }, + noteAuthorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + }, + directives: { + tooltip, + }, + computed: { + ...mapGetters([ + 'getUserData', + ]), + // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. + // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] + // This method will group emojis by their name as an Object. See below. + // { + // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], + // bar: [ { name: bar, user: user1 } ] + // } + // We need to do this otherwise we will render the same emoji over and over again. + groupedAwards() { + const awards = this.awards.reduce((acc, award) => { + if (Object.prototype.hasOwnProperty.call(acc, award.name)) { + acc[award.name].push(award); + } else { + Object.assign(acc, { [award.name]: [award] }); + } + + return acc; + }, {}); + + const orderedAwards = {}; + const { thumbsdown, thumbsup } = awards; + // Always show thumbsup and thumbsdown first + if (thumbsup) { + orderedAwards.thumbsup = thumbsup; + delete awards.thumbsup; + } + if (thumbsdown) { + orderedAwards.thumbsdown = thumbsdown; + delete awards.thumbsdown; + } + + return Object.assign({}, orderedAwards, awards); + }, + isAuthoredByMe() { + return this.noteAuthorId === this.getUserData.id; + }, + isLoggedIn() { + return this.getUserData.id; + }, + }, + methods: { + ...mapActions([ + 'toggleAwardRequest', + ]), + getAwardHTML(name) { + return glEmojiTag(name); + }, + getAwardClassBindings(awardList, awardName) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: !this.canInteractWithEmoji(awardList, awardName), + }; + }, + canInteractWithEmoji(awardList, awardName) { + let isAllowed = true; + const restrictedEmojis = ['thumbsup', 'thumbsdown']; + + // Users can not add :+1: and :-1: to their own notes + if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) { + isAllowed = false; + } + + return this.getUserData.id && isAllowed; + }, + hasReactionByCurrentUser(awardList) { + return awardList.filter(award => award.user.id === this.getUserData.id).length; + }, + awardTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter(award => award.user.id !== this.getUserData.id); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); + + // Add myself to the begining of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift('You'); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`; + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += ` and ${namesToShow.slice(-1)}`; // Append and text + } else { // We have only 2 users so join them with and. + title = namesToShow.join(' and '); + } + + return title; + }, + handleAward(awardName) { + if (!this.isLoggedIn) { + return; + } + + let parsedName; + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + switch (awardName) { + case '100': + parsedName = 100; + break; + case '1234': + parsedName = 1234; + break; + default: + parsedName = awardName; + break; + } + + const data = { + endpoint: this.toggleAwardPath, + noteId: this.noteId, + awardName: parsedName, + }; + + this.toggleAwardRequest(data) + .catch(() => Flash('Something went wrong on our end.')); + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + }, + }; +</script> + +<template> + <div class="note-awards"> + <div class="awards js-awards-block"> + <button + v-tooltip + v-for="(awardList, awardName, index) in groupedAwards" + :key="index" + :class="getAwardClassBindings(awardList, awardName)" + :title="awardTitle(awardList)" + @click="handleAward(awardName)" + class="btn award-control" + data-placement="bottom" + type="button"> + <span v-html="getAwardHTML(awardName)"></span> + <span class="award-control-text js-counter"> + {{awardList.length}} + </span> + </button> + <div + v-if="isLoggedIn" + class="award-menu-holder"> + <button + v-tooltip + :class="{ 'js-user-authored': isAuthoredByMe }" + class="award-control btn js-add-award" + title="Add reaction" + aria-label="Add reaction" + data-placement="bottom" + type="button"> + <span + v-html="emojiSmiling" + class="award-control-icon award-control-icon-neutral"> + </span> + <span + v-html="emojiSmiley" + class="award-control-icon award-control-icon-positive"> + </span> + <span + v-html="emojiSmile" + class="award-control-icon award-control-icon-super-positive"> + </span> + <i + aria-hidden="true" + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i> + </button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_body.vue b/app/assets/javascripts/notes/components/issue_note_body.vue new file mode 100644 index 00000000000..5f9003bfd87 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_body.vue @@ -0,0 +1,122 @@ +<script> + import issueNoteEditedText from './issue_note_edited_text.vue'; + import issueNoteAwardsList from './issue_note_awards_list.vue'; + import issueNoteAttachment from './issue_note_attachment.vue'; + import issueNoteForm from './issue_note_form.vue'; + import TaskList from '../../task_list'; + import autosave from '../mixins/autosave'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, + }, + mixins: [ + autosave, + ], + components: { + issueNoteEditedText, + issueNoteAwardsList, + issueNoteAttachment, + issueNoteForm, + }, + computed: { + noteBody() { + return this.note.note; + }, + }, + methods: { + renderGFM() { + $(this.$refs['note-body']).renderGFM(); + }, + initTaskList() { + if (this.canEdit) { + this.taskList = new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); + } + }, + handleFormUpdate(note, parentElement, callback) { + this.$emit('handleFormUpdate', note, parentElement, callback); + }, + formCancelHandler(shouldConfirm, isDirty) { + this.$emit('cancelFormEdition', shouldConfirm, isDirty); + }, + }, + mounted() { + this.renderGFM(); + this.initTaskList(); + + if (this.isEditing) { + this.initAutoSave(); + } + }, + updated() { + this.initTaskList(); + this.renderGFM(); + + if (this.isEditing) { + if (!this.autosave) { + this.initAutoSave(); + } else { + this.setAutoSave(); + } + } + }, + }; +</script> + +<template> + <div + :class="{ 'js-task-list-container': canEdit }" + ref="note-body" + class="note-body"> + <div + v-html="note.note_html" + class="note-text md"></div> + <issue-note-form + v-if="isEditing" + ref="noteForm" + @handleFormUpdate="handleFormUpdate" + @cancelFormEdition="formCancelHandler" + :is-editing="isEditing" + :note-body="noteBody" + :note-id="note.id" + /> + <textarea + v-if="canEdit" + v-model="note.note" + :data-update-url="note.path" + class="hidden js-task-list-field"></textarea> + <issue-note-edited-text + v-if="note.last_edited_at" + :edited-at="note.last_edited_at" + :edited-by="note.last_edited_by" + action-text="Edited" + /> + <issue-note-awards-list + v-if="note.award_emoji.length" + :note-id="note.id" + :note-author-id="note.author.id" + :awards="note.award_emoji" + :toggle-award-path="note.toggle_award_path" + /> + <issue-note-attachment + v-if="note.attachment" + :attachment="note.attachment" + /> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_edited_text.vue b/app/assets/javascripts/notes/components/issue_note_edited_text.vue new file mode 100644 index 00000000000..49e09f0ecc5 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_edited_text.vue @@ -0,0 +1,47 @@ +<script> + import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + + export default { + name: 'editedNoteText', + props: { + actionText: { + type: String, + required: true, + }, + editedAt: { + type: String, + required: true, + }, + editedBy: { + type: Object, + required: false, + }, + className: { + type: String, + required: false, + default: 'edited-text', + }, + }, + components: { + timeAgoTooltip, + }, + }; +</script> + +<template> + <div :class="className"> + {{actionText}} + <time-ago-tooltip + :time="editedAt" + tooltip-placement="bottom" + /> + <template v-if="editedBy"> + by + <a + :href="editedBy.path" + class="js-vue-author author_link"> + {{editedBy.name}} + </a> + </template> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue new file mode 100644 index 00000000000..626c0f2ce18 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_form.vue @@ -0,0 +1,166 @@ +<script> + import { mapGetters } from 'vuex'; + import eventHub from '../event_hub'; + import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import markdownField from '../../vue_shared/components/markdown/field.vue'; + + export default { + name: 'issueNoteForm', + props: { + noteBody: { + type: String, + required: false, + default: '', + }, + noteId: { + type: Number, + required: false, + }, + saveButtonTitle: { + type: String, + required: false, + default: 'Save comment', + }, + discussion: { + type: Object, + required: false, + default: () => ({}), + }, + isEditing: { + type: Boolean, + required: true, + }, + }, + data() { + return { + note: this.noteBody, + conflictWhileEditing: false, + isSubmitting: false, + }; + }, + components: { + confidentialIssue, + markdownField, + }, + computed: { + ...mapGetters([ + 'getDiscussionLastNote', + 'getIssueDataByProp', + 'getNotesDataByProp', + 'getUserDataByProp', + ]), + noteHash() { + return `#note_${this.noteId}`; + }, + markdownPreviewPath() { + return this.getIssueDataByProp('preview_note_path'); + }, + markdownDocsPath() { + return this.getNotesDataByProp('markdownDocsPath'); + }, + quickActionsDocsPath() { + return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + isDisabled() { + return !this.note.length || this.isSubmitting; + }, + isConfidentialIssue() { + return this.getIssueDataByProp('confidential'); + }, + }, + methods: { + handleUpdate() { + this.isSubmitting = true; + + this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { + this.isSubmitting = false; + }); + }, + editMyLastNote() { + if (this.note === '') { + const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); + + if (lastNoteInDiscussion) { + eventHub.$emit('enterEditMode', { + noteId: lastNoteInDiscussion.id, + }); + } + } + }, + cancelHandler(shouldConfirm = false) { + // Sends information about confirm message and if the textarea has changed + this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); + }, + }, + mounted() { + this.$refs.textarea.focus(); + }, + watch: { + noteBody() { + if (this.note === this.noteBody) { + this.note = this.noteBody; + } else { + this.conflictWhileEditing = true; + } + }, + }, + }; +</script> + +<template> + <div ref="editNoteForm" class="note-edit-form current-note-edit-form"> + <div + v-if="conflictWhileEditing" + class="js-conflict-edit-warning alert alert-danger"> + This comment has changed since you started editing, please review the + <a + :href="noteHash" + target="_blank" + rel="noopener noreferrer">updated comment</a> + 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"> + <confidentialIssue v-if="isConfidentialIssue" /> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :add-spacing-classes="false"> + <textarea + id="note_note" + name="note[note]" + class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" + :data-supports-quick-actions="!isEditing" + aria-label="Description" + v-model="note" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.meta.enter="handleUpdate()" + @keydown.up="editMyLastNote()" + @keydown.esc="cancelHandler(true)"> + </textarea> + </markdown-field> + <div class="note-form-actions clearfix"> + <button + type="button" + @click="handleUpdate()" + :disabled="isDisabled" + class="js-vue-issue-save btn btn-save"> + {{saveButtonTitle}} + </button> + <button + @click="cancelHandler()" + class="btn btn-cancel note-edit-cancel" + type="button"> + Cancel + </button> + </div> + </form> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_header.vue b/app/assets/javascripts/notes/components/issue_note_header.vue new file mode 100644 index 00000000000..63aa3d777d0 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_header.vue @@ -0,0 +1,118 @@ +<script> + import { mapActions } from 'vuex'; + import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + + export default { + props: { + author: { + type: Object, + required: true, + }, + createdAt: { + type: String, + required: true, + }, + actionText: { + type: String, + required: false, + default: '', + }, + actionTextHtml: { + type: String, + required: false, + default: '', + }, + noteId: { + type: Number, + required: true, + }, + includeToggle: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isExpanded: true, + }; + }, + components: { + timeAgoTooltip, + }, + computed: { + toggleChevronClass() { + return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; + }, + noteTimestampLink() { + return `#note_${this.noteId}`; + }, + }, + methods: { + ...mapActions([ + 'setTargetNoteHash', + ]), + handleToggle() { + this.isExpanded = !this.isExpanded; + this.$emit('toggleHandler'); + }, + updateTargetNoteHash() { + this.setTargetNoteHash(this.noteTimestampLink); + }, + }, + }; +</script> + +<template> + <div class="note-header-info"> + <a :href="author.path"> + <span class="note-header-author-name"> + {{author.name}} + </span> + <span class="note-headline-light"> + @{{author.username}} + </span> + </a> + <span class="note-headline-light"> + <span class="note-headline-meta"> + <template v-if="actionText"> + {{actionText}} + </template> + <span + v-if="actionTextHtml" + v-html="actionTextHtml" + class="system-note-message"> + </span> + <a + :href="noteTimestampLink" + @click="updateTargetNoteHash" + class="note-timestamp"> + <time-ago-tooltip + :time="createdAt" + tooltip-placement="bottom" + /> + </a> + <i + class="fa fa-spinner fa-spin editing-spinner" + aria-label="Comment is being updated" + aria-hidden="true"> + </i> + </span> + </span> + <div + v-if="includeToggle" + class="discussion-actions"> + <button + @click="handleToggle" + class="note-action-button discussion-toggle-button js-vue-toggle-button" + type="button"> + <i + :class="toggleChevronClass" + class="fa" + aria-hidden="true"> + </i> + Toggle discussion + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_icons.js b/app/assets/javascripts/notes/components/issue_note_icons.js new file mode 100644 index 00000000000..d8e3cb4bc01 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_icons.js @@ -0,0 +1,37 @@ +import iconArrowCircle from 'icons/_icon_arrow_circle_o_right.svg'; +import iconCheck from 'icons/_icon_check_square_o.svg'; +import iconClock from 'icons/_icon_clock_o.svg'; +import iconCodeFork from 'icons/_icon_code_fork.svg'; +import iconComment from 'icons/_icon_comment_o.svg'; +import iconCommit from 'icons/_icon_commit.svg'; +import iconEdit from 'icons/_icon_edit.svg'; +import iconEye from 'icons/_icon_eye.svg'; +import iconEyeSlash from 'icons/_icon_eye_slash.svg'; +import iconMerge from 'icons/_icon_merge.svg'; +import iconMerged from 'icons/_icon_merged.svg'; +import iconRandom from 'icons/_icon_random.svg'; +import iconClosed from 'icons/_icon_status_closed.svg'; +import iconStatusOpen from 'icons/_icon_status_open.svg'; +import iconStopwatch from 'icons/_icon_stopwatch.svg'; +import iconTags from 'icons/_icon_tags.svg'; +import iconUser from 'icons/_icon_user.svg'; + +export default { + icon_arrow_circle_o_right: iconArrowCircle, + icon_check_square_o: iconCheck, + icon_clock_o: iconClock, + icon_code_fork: iconCodeFork, + icon_comment_o: iconComment, + icon_commit: iconCommit, + icon_edit: iconEdit, + icon_eye: iconEye, + icon_eye_slash: iconEyeSlash, + icon_merge: iconMerge, + icon_merged: iconMerged, + icon_random: iconRandom, + icon_status_closed: iconClosed, + icon_status_open: iconStatusOpen, + icon_stopwatch: iconStopwatch, + icon_tags: iconTags, + icon_user: iconUser, +}; diff --git a/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue new file mode 100644 index 00000000000..77af3594c1c --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue @@ -0,0 +1,28 @@ +<script> + import { mapGetters } from 'vuex'; + + export default { + name: 'singInLinksNotes', + computed: { + ...mapGetters([ + 'getNotesDataByProp', + ]), + registerLink() { + return this.getNotesDataByProp('registerPath'); + }, + signInLink() { + return this.getNotesDataByProp('newSessionPath'); + }, + }, + }; +</script> + +<template> + <div class="disabled-comment text-center"> + Please + <a :href="registerLink">register</a> + or + <a :href="signInLink">sign in</a> + to reply + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/issue_notes_app.vue new file mode 100644 index 00000000000..b6fc5e5036f --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_notes_app.vue @@ -0,0 +1,151 @@ +<script> + /* global Flash */ + import { mapGetters, mapActions } from 'vuex'; + import store from '../stores/'; + import * as constants from '../constants'; + import issueNote from './issue_note.vue'; + import issueDiscussion from './issue_discussion.vue'; + import issueSystemNote from './issue_system_note.vue'; + import issueCommentForm from './issue_comment_form.vue'; + import placeholderNote from './issue_placeholder_note.vue'; + import placeholderSystemNote from './issue_placeholder_system_note.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + + export default { + name: 'issueNotesApp', + props: { + issueData: { + type: Object, + required: true, + }, + notesData: { + type: Object, + required: true, + }, + userData: { + type: Object, + required: false, + default: {}, + }, + }, + store, + data() { + return { + isLoading: true, + }; + }, + components: { + issueNote, + issueDiscussion, + issueSystemNote, + issueCommentForm, + loadingIcon, + placeholderNote, + placeholderSystemNote, + }, + computed: { + ...mapGetters([ + 'notes', + 'getNotesDataByProp', + ]), + }, + methods: { + ...mapActions({ + actionFetchNotes: 'fetchNotes', + poll: 'poll', + actionToggleAward: 'toggleAward', + scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', + setNotesData: 'setNotesData', + setIssueData: 'setIssueData', + setUserData: 'setUserData', + setLastFetchedAt: 'setLastFetchedAt', + setTargetNoteHash: 'setTargetNoteHash', + }), + getComponentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === constants.SYSTEM_NOTE) { + return placeholderSystemNote; + } + return placeholderNote; + } else if (note.individual_note) { + return note.notes[0].system ? issueSystemNote : issueNote; + } + + return issueDiscussion; + }, + getComponentData(note) { + return note.individual_note ? note.notes[0] : note; + }, + fetchNotes() { + return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) + .then(() => this.initPolling()) + .then(() => { + this.isLoading = false; + }) + .then(() => this.$nextTick()) + .then(() => this.checkLocationHash()) + .catch(() => { + this.isLoading = false; + Flash('Something went wrong while fetching issue comments. Please try again.'); + }); + }, + initPolling() { + this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); + + this.poll(); + }, + checkLocationHash() { + const hash = gl.utils.getLocationHash(); + const element = document.getElementById(hash); + + if (hash && element) { + this.setTargetNoteHash(hash); + this.scrollToNoteIfNeeded($(element)); + } + }, + }, + created() { + this.setNotesData(this.notesData); + this.setIssueData(this.issueData); + this.setUserData(this.userData); + }, + mounted() { + this.fetchNotes(); + + const parentElement = this.$el.parentElement; + + if (parentElement && + parentElement.classList.contains('js-vue-notes-event')) { + parentElement.addEventListener('toggleAward', (event) => { + const { awardName, noteId } = event.detail; + this.actionToggleAward({ awardName, noteId }); + }); + } + }, + }; +</script> + +<template> + <div id="notes"> + <div + v-if="isLoading" + class="js-loading loading"> + <loading-icon /> + </div> + + <ul + v-if="!isLoading" + id="notes-list" + class="notes main-notes-list timeline"> + + <component + v-for="note in notes" + :is="getComponentName(note)" + :note="getComponentData(note)" + :key="note.id" + /> + </ul> + + <issue-comment-form /> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_placeholder_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_note.vue new file mode 100644 index 00000000000..6921d91372f --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_placeholder_note.vue @@ -0,0 +1,53 @@ +<script> + import { mapGetters } from 'vuex'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + + export default { + name: 'issuePlaceholderNote', + props: { + note: { + type: Object, + required: true, + }, + }, + components: { + userAvatarLink, + }, + computed: { + ...mapGetters([ + 'getUserData', + ]), + }, + }; +</script> + +<template> + <li class="note being-posted fade-in-half timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="getUserData.path" + :img-src="getUserData.avatar_url" + :img-size="40" + /> + </div> + <div + :class="{ discussion: !note.individual_note }" + class="timeline-content"> + <div class="note-header"> + <div class="note-header-info"> + <a :href="getUserData.path"> + <span class="hidden-xs">{{getUserData.name}}</span> + <span class="note-headline-light">@{{getUserData.username}}</span> + </a> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>{{note.body}}</p> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue new file mode 100644 index 00000000000..80a8ef56a83 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue @@ -0,0 +1,21 @@ +<script> + export default { + name: 'placeholderSystemNote', + props: { + note: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <li class="note system-note timeline-entry being-posted fade-in-half"> + <div class="timeline-entry-inner"> + <div class="timeline-content"> + <em>{{note.body}}</em> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_system_note.vue b/app/assets/javascripts/notes/components/issue_system_note.vue new file mode 100644 index 00000000000..5bb8f871b9d --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_system_note.vue @@ -0,0 +1,55 @@ +<script> + import { mapGetters } from 'vuex'; + import iconsMap from './issue_note_icons'; + import issueNoteHeader from './issue_note_header.vue'; + + export default { + name: 'systemNote', + props: { + note: { + type: Object, + required: true, + }, + }, + components: { + issueNoteHeader, + }, + computed: { + ...mapGetters([ + 'targetNoteHash', + ]), + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; + }, + }, + created() { + this.svg = iconsMap[this.note.system_note_icon_name]; + }, + }; +</script> + +<template> + <li + :id="noteAnchorId" + :class="{ target: isTargetNote }" + class="note system-note timeline-entry"> + <div class="timeline-entry-inner"> + <div + class="timeline-icon" + v-html="svg"> + </div> + <div class="timeline-content"> + <div class="note-header"> + <issue-note-header + :author="note.author" + :created-at="note.created_at" + :note-id="note.id" + :action-text-html="note.note_html" /> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js new file mode 100644 index 00000000000..a6961063c01 --- /dev/null +++ b/app/assets/javascripts/notes/constants.js @@ -0,0 +1,11 @@ +export const DISCUSSION_NOTE = 'DiscussionNote'; +export const DISCUSSION = 'discussion'; +export const NOTE = 'note'; +export const SYSTEM_NOTE = 'systemNote'; +export const COMMENT = 'comment'; +export const OPENED = 'opened'; +export const REOPENED = 'reopened'; +export const CLOSED = 'closed'; +export const EMOJI_THUMBSUP = 'thumbsup'; +export const EMOJI_THUMBSDOWN = 'thumbsdown'; +export const NOTEABLE_TYPE = 'Issue'; diff --git a/app/assets/javascripts/notes/event_hub.js b/app/assets/javascripts/notes/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/notes/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js new file mode 100644 index 00000000000..e2ea37408cf --- /dev/null +++ b/app/assets/javascripts/notes/index.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import issueNotesApp from './components/issue_notes_app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-vue-notes', + components: { + issueNotesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-notes').dataset; + + return { + issueData: JSON.parse(notesDataset.issueData), + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: { + lastFetchedAt: notesDataset.lastFetchedAt, + discussionsPath: notesDataset.discussionsPath, + newSessionPath: notesDataset.newSessionPath, + registerPath: notesDataset.registerPath, + notesPath: notesDataset.notesPath, + markdownDocsPath: notesDataset.markdownDocsPath, + quickActionsDocsPath: notesDataset.quickActionsDocsPath, + }, + }; + }, + render(createElement) { + return createElement('issue-notes-app', { + props: { + issueData: this.issueData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, +})); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js new file mode 100644 index 00000000000..5843b97f225 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -0,0 +1,16 @@ +/* globals Autosave */ +import '../../autosave'; + +export default { + methods: { + initAutoSave() { + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); + }, + resetAutoSave() { + this.autosave.reset(); + }, + setAutoSave() { + this.autosave.save(); + }, + }, +}; diff --git a/app/assets/javascripts/notes/services/issue_notes_service.js b/app/assets/javascripts/notes/services/issue_notes_service.js new file mode 100644 index 00000000000..b51b0cb2013 --- /dev/null +++ b/app/assets/javascripts/notes/services/issue_notes_service.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default { + fetchNotes(endpoint) { + return Vue.http.get(endpoint); + }, + deleteNote(endpoint) { + return Vue.http.delete(endpoint); + }, + replyToDiscussion(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, + updateNote(endpoint, data) { + return Vue.http.put(endpoint, data, { emulateJSON: true }); + }, + createNewNote(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, + poll(data = {}) { + const { endpoint, lastFetchedAt } = data; + const options = { + headers: { + 'X-Last-Fetched-At': lastFetchedAt, + }, + }; + + return Vue.http.get(endpoint, options); + }, + toggleAward(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js new file mode 100644 index 00000000000..13cd74bfa1c --- /dev/null +++ b/app/assets/javascripts/notes/stores/actions.js @@ -0,0 +1,217 @@ +/* global Flash */ +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import * as types from './mutation_types'; +import * as utils from './utils'; +import * as constants from '../constants'; +import service from '../services/issue_notes_service'; +import loadAwardsHandler from '../../awards_handler'; +import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; + +let eTagPoll; + +export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data); +export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data); +export const 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 setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); +export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); + +export const fetchNotes = ({ commit }, path) => service + .fetchNotes(path) + .then(res => res.json()) + .then((res) => { + commit(types.SET_INITIAL_NOTES, res); + }); + +export const deleteNote = ({ commit }, note) => service + .deleteNote(note.path) + .then(() => { + commit(types.DELETE_NOTE, note); + }); + +export const updateNote = ({ commit }, { endpoint, note }) => service + .updateNote(endpoint, note) + .then(res => res.json()) + .then((res) => { + commit(types.UPDATE_NOTE, res); + }); + +export const replyToDiscussion = ({ commit }, { endpoint, data }) => service + .replyToDiscussion(endpoint, data) + .then(res => res.json()) + .then((res) => { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); + + return res; + }); + +export const createNewNote = ({ commit }, { endpoint, data }) => service + .createNewNote(endpoint, data) + .then(res => res.json()) + .then((res) => { + if (!res.errors) { + commit(types.ADD_NEW_NOTE, res); + } + return res; + }); + +export const removePlaceholderNotes = ({ commit }) => + commit(types.REMOVE_PLACEHOLDER_NOTES); + +export const saveNote = ({ commit, dispatch }, noteData) => { + const { note } = noteData.data.note; + let placeholderText = note; + const hasQuickActions = utils.hasQuickActions(placeholderText); + const replyId = noteData.data.in_reply_to_discussion_id; + const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote'; + + commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders + $('.notes-form .flash-container').hide(); // hide previous flash notification + + if (hasQuickActions) { + placeholderText = utils.stripQuickActions(placeholderText); + } + + if (placeholderText.length) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + noteBody: placeholderText, + replyId, + }); + } + + if (hasQuickActions) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + isSystemNote: true, + noteBody: utils.getQuickActionText(note), + replyId, + }); + } + + return dispatch(methodToDispatch, noteData) + .then((res) => { + const { errors } = res; + const commandsChanges = res.commands_changes; + + if (hasQuickActions && errors && Object.keys(errors).length) { + eTagPoll.makeRequest(); + + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); + Flash('Commands applied', 'notice', $(noteData.flashContainer)); + } + + if (commandsChanges) { + if (commandsChanges.emoji_award) { + const votesBlock = $('.js-awards-block').eq(0); + + loadAwardsHandler() + .then((awardsHandler) => { + awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + Flash( + 'Something went wrong while adding your award. Please try again.', + null, + $(noteData.flashContainer), + ); + }); + } + + if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) { + sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); + } + } + + if (errors && errors.commands_only) { + Flash(errors.commands_only, 'notice', $(noteData.flashContainer)); + } + commit(types.REMOVE_PLACEHOLDER_NOTES); + + return res; + }); +}; + +const pollSuccessCallBack = (resp, commit, state, getters) => { + if (resp.notes && resp.notes.length) { + const { notesById } = getters; + + resp.notes.forEach((note) => { + if (notesById[note.id]) { + commit(types.UPDATE_NOTE, note); + } else if (note.type === constants.DISCUSSION_NOTE) { + const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (discussion) { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); + } else { + commit(types.ADD_NEW_NOTE, note); + } + } else { + commit(types.ADD_NEW_NOTE, note); + } + }); + } + + commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt); + + return resp; +}; + +export const poll = ({ commit, state, getters }) => { + const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + + eTagPoll = new Poll({ + resource: service, + method: 'poll', + data: requestData, + successCallback: resp => resp.json() + .then(data => pollSuccessCallBack(data, commit, state, getters)), + errorCallback: () => Flash('Something went wrong while fetching latest comments.'), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } else { + service.poll(requestData); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + eTagPoll.restart(); + } else { + eTagPoll.stop(); + } + }); +}; + +export const fetchData = ({ commit, state, getters }) => { + const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + + service.poll(requestData) + .then(resp => resp.json) + .then(data => pollSuccessCallBack(data, commit, state, getters)) + .catch(() => Flash('Something went wrong while fetching latest comments.')); +}; + +export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => { + commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] }); +}; + +export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => { + const { endpoint, awardName } = data; + + return service + .toggleAward(endpoint, { name: awardName }) + .then(res => res.json()) + .then(() => { + dispatch('toggleAward', data); + }); +}; + +export const scrollToNoteIfNeeded = (context, el) => { + if (!gl.utils.isInViewport(el[0])) { + gl.utils.scrollToElement(el); + } +}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js new file mode 100644 index 00000000000..1f0c6af6156 --- /dev/null +++ b/app/assets/javascripts/notes/stores/getters.js @@ -0,0 +1,31 @@ +import _ from 'underscore'; + +export const notes = state => state.notes; +export const targetNoteHash = state => state.targetNoteHash; + +export const getNotesData = state => state.notesData; +export const getNotesDataByProp = state => prop => state.notesData[prop]; + +export const getIssueData = state => state.issueData; +export const getIssueDataByProp = state => prop => state.issueData[prop]; + +export const getUserData = state => state.userData || {}; +export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; + +export const notesById = state => state.notes.reduce((acc, note) => { + note.notes.every(n => Object.assign(acc, { [n.id]: n })); + return acc; +}, {}); + +const reverseNotes = array => array.slice(0).reverse(); +const isLastNote = (note, state) => !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)); + +export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) + .find(el => isLastNote(el, state)); diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js new file mode 100644 index 00000000000..8e0c8531bbc --- /dev/null +++ b/app/assets/javascripts/notes/stores/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + + // holds endpoints and permissions provided through haml + notesData: {}, + userData: {}, + issueData: {}, + }, + actions, + getters, + mutations, +}); diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js new file mode 100644 index 00000000000..cd71533ba9d --- /dev/null +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -0,0 +1,14 @@ +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 REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; +export const SET_NOTES_DATA = 'SET_NOTES_DATA'; +export const SET_ISSUE_DATA = 'SET_ISSUE_DATA'; +export const SET_USER_DATA = 'SET_USER_DATA'; +export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES'; +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'; +export const TOGGLE_AWARD = 'TOGGLE_AWARD'; +export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; +export const UPDATE_NOTE = 'UPDATE_NOTE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js new file mode 100644 index 00000000000..3b2b2089d6e --- /dev/null +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -0,0 +1,151 @@ +import * as utils from './utils'; +import * as types from './mutation_types'; +import * as constants from '../constants'; + +export default { + [types.ADD_NEW_NOTE](state, note) { + const { discussion_id, type } = note; + const noteData = { + expanded: true, + id: discussion_id, + individual_note: !(type === constants.DISCUSSION_NOTE), + notes: [note], + reply_id: discussion_id, + }; + + state.notes.push(noteData); + }, + + [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj) { + noteObj.notes.push(note); + } + }, + + [types.DELETE_NOTE](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj.individual_note) { + state.notes.splice(state.notes.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); + } + } + }, + + [types.REMOVE_PLACEHOLDER_NOTES](state) { + const { notes } = state; + + for (let i = notes.length - 1; i >= 0; i -= 1) { + const note = notes[i]; + const children = note.notes; + + if (children.length && !note.individual_note) { // remove placeholder from discussions + for (let j = children.length - 1; j >= 0; j -= 1) { + if (children[j].isPlaceholderNote) { + children.splice(j, 1); + } + } + } else if (note.isPlaceholderNote) { // remove placeholders from state root + notes.splice(i, 1); + } + } + }, + + [types.SET_NOTES_DATA](state, data) { + Object.assign(state, { notesData: data }); + }, + + [types.SET_ISSUE_DATA](state, data) { + Object.assign(state, { issueData: data }); + }, + + [types.SET_USER_DATA](state, data) { + Object.assign(state, { userData: data }); + }, + [types.SET_INITIAL_NOTES](state, notesData) { + const notes = []; + + notesData.forEach((note) => { + // To support legacy notes, should be very rare case. + if (note.individual_note && note.notes.length > 1) { + note.notes.forEach((n) => { + const nn = Object.assign({}, note); + nn.notes = [n]; // override notes array to only have one item to mimick individual_note + notes.push(nn); + }); + } else { + notes.push(note); + } + }); + + Object.assign(state, { notes }); + }, + + [types.SET_LAST_FETCHED_AT](state, fetchedAt) { + Object.assign(state, { lastFetchedAt: fetchedAt }); + }, + + [types.SET_TARGET_NOTE_HASH](state, hash) { + Object.assign(state, { targetNoteHash: hash }); + }, + + [types.SHOW_PLACEHOLDER_NOTE](state, data) { + let notesArr = state.notes; + if (data.replyId) { + notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes; + } + + notesArr.push({ + individual_note: true, + isPlaceholderNote: true, + placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, + notes: [ + { + body: data.noteBody, + }, + ], + }); + }, + + [types.TOGGLE_AWARD](state, data) { + const { awardName, note } = data; + const { id, name, username } = state.userData; + + const hasEmojiAwardedByCurrentUser = note.award_emoji + .filter(emoji => emoji.name === data.awardName && emoji.user.id === id); + + if (hasEmojiAwardedByCurrentUser.length) { + // If current user has awarded this emoji, remove it. + note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1); + } else { + note.award_emoji.push({ + name: awardName, + user: { id, name, username }, + }); + } + }, + + [types.TOGGLE_DISCUSSION](state, { discussionId }) { + const discussion = utils.findNoteObjectById(state.notes, discussionId); + + discussion.expanded = !discussion.expanded; + }, + + [types.UPDATE_NOTE](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj.individual_note) { + noteObj.notes.splice(0, 1, note); + } else { + const comment = utils.findNoteObjectById(noteObj.notes, note.id); + noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); + } + }, +}; diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js new file mode 100644 index 00000000000..6074115e855 --- /dev/null +++ b/app/assets/javascripts/notes/stores/utils.js @@ -0,0 +1,31 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + +const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; + +export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0]; + +export const getQuickActionText = (note) => { + let text = 'Applying command'; + const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; + + const executedCommands = quickActions.filter((command) => { + const commandRegex = new RegExp(`/${command.name}`); + return commandRegex.test(note); + }); + + if (executedCommands && executedCommands.length) { + if (executedCommands.length > 1) { + text = 'Applying multiple commands'; + } else { + const commandDescription = executedCommands[0].description.toLowerCase(); + text = `Applying command to ${commandDescription}`; + } + } + + return text; +}; + +export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); + +export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); + 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 7695b04db74..3e5d6d15909 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -72,7 +72,7 @@ }; </script> <template> - <div> + <div class="ci-job-dropdown-container"> <button v-tooltip type="button" diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 1f5ed3f1074..3933509a6f4 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -75,7 +75,7 @@ }; </script> <template> - <div> + <div class="ci-job-component"> <a v-tooltip v-if="job.status.details_path" 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 d8856e10668..f46d21bd6d7 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -26,7 +26,7 @@ }; </script> <template> - <span> + <span class="ci-job-name-component"> <ci-icon :status="status" /> diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.vue b/app/assets/javascripts/pipelines/components/navigation_tabs.vue index d2f6d47f043..73f7e3a0cad 100644 --- a/app/assets/javascripts/pipelines/components/navigation_tabs.vue +++ b/app/assets/javascripts/pipelines/components/navigation_tabs.vue @@ -1,23 +1,29 @@ <script> -export default { - name: 'PipelineNavigationTabs', - props: { - scope: { - type: String, - required: true, + export default { + name: 'PipelineNavigationTabs', + props: { + scope: { + type: String, + required: true, + }, + count: { + type: Object, + required: true, + }, + paths: { + type: Object, + required: true, + }, }, - count: { - type: Object, - required: true, + mounted() { + $(document).trigger('init.scrolling-tabs'); }, - paths: { - type: Object, - required: true, + methods: { + shouldRenderBadge(count) { + // 0 is valid in a badge, but evaluates to false, we need to check for undefined + return count !== undefined; + }, }, - }, - mounted() { - $(document).trigger('init.scrolling-tabs'); - }, }; </script> <template> @@ -27,7 +33,9 @@ export default { :class="{ active: scope === 'all'}"> <a :href="paths.allPath"> All - <span class="badge js-totalbuilds-count"> + <span + v-if="shouldRenderBadge(count.all)" + class="badge js-totalbuilds-count"> {{count.all}} </span> </a> @@ -37,7 +45,9 @@ export default { :class="{ active: scope === 'pending'}"> <a :href="paths.pendingPath"> Pending - <span class="badge"> + <span + v-if="shouldRenderBadge(count.pending)" + class="badge"> {{count.pending}} </span> </a> @@ -47,7 +57,9 @@ export default { :class="{ active: scope === 'running'}"> <a :href="paths.runningPath"> Running - <span class="badge"> + <span + v-if="shouldRenderBadge(count.running)" + class="badge"> {{count.running}} </span> </a> @@ -57,7 +69,9 @@ export default { :class="{ active: scope === 'finished'}"> <a :href="paths.finishedPath"> Finished - <span class="badge"> + <span + v-if="shouldRenderBadge(count.finished)" + class="badge"> {{count.finished}} </span> </a> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index 5df317a76bf..010063a0240 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -139,7 +139,9 @@ }; </script> <template> - <div :class="cssClass"> + <div + class="pipelines-container" + :class="cssClass"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs" v-if="!isLoading && !shouldRenderEmptyState"> diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index d7e3ab42f00..fe6602259e2 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -53,10 +53,6 @@ import Cookies from 'js-cookie'; return _this.changeProject($(e.currentTarget).val()); }; })(this)); - return $('.js-projects-dropdown-toggle').on('click', function(e) { - e.preventDefault(); - return $('.js-projects-dropdown').select2('open'); - }); }; Project.prototype.changeProject = function(url) { diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 1b4ed6be90a..fb01390f91c 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -5,48 +5,6 @@ import ProjectSelectComboButton from './project_select_combo_button'; (function() { this.ProjectSelect = (function() { function ProjectSelect() { - $('.js-projects-dropdown-toggle').each(function(i, dropdown) { - var $dropdown; - $dropdown = $(dropdown); - return $dropdown.glDropdown({ - filterable: true, - filterRemote: true, - search: { - fields: ['name_with_namespace'] - }, - data: function(term, callback) { - var finalCallback, projectsCallback; - var orderBy = $dropdown.data('order-by'); - finalCallback = function(projects) { - return callback(projects); - }; - if (this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { - var data; - data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (this.groupId) { - return Api.groupProjects(this.groupId, term, projectsCallback); - } else { - return Api.projects(term, { order_by: orderBy }, projectsCallback); - } - }, - url: function(project) { - return project.web_url; - }, - text: function(project) { - return project.name_with_namespace; - } - }); - }); $('.ajax-project-select').each(function(i, select) { var placeholder; this.groupId = $(select).data('group-id'); diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index 46a26fb91f4..99cea683d9a 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -14,7 +14,14 @@ export default class ProjectSelectComboButton { bindEvents() { this.projectSelectInput.siblings('.new-project-item-select-button') - .on('click', this.openDropdown); + .on('click', e => this.openDropdown(e)); + + this.newItemBtn.on('click', (e) => { + if (!this.getProjectFromLocalStorage()) { + e.preventDefault(); + this.openDropdown(e); + } + }); this.projectSelectInput.on('change', () => this.selectProject()); } @@ -28,8 +35,9 @@ export default class ProjectSelectComboButton { } } - openDropdown() { - $(this).siblings('.project-item-select').select2('open'); + // eslint-disable-next-line class-methods-use-this + openDropdown(event) { + $(event.currentTarget).siblings('.project-item-select').select2('open'); } selectProject() { @@ -56,10 +64,8 @@ export default class ProjectSelectComboButton { if (project) { this.newItemBtn.attr('href', project.url); this.newItemBtn.text(`${this.formattedText.defaultTextPrefix} in ${project.name}`); - this.newItemBtn.enable(); } else { this.newItemBtn.text(`Select project to create ${this.formattedText.presetTextSuffix}`); - this.newItemBtn.disable(); } } diff --git a/app/assets/javascripts/project_visibility.js b/app/assets/javascripts/project_visibility.js new file mode 100644 index 00000000000..c3f5e8cb907 --- /dev/null +++ b/app/assets/javascripts/project_visibility.js @@ -0,0 +1,41 @@ +function setVisibilityOptions(namespaceSelector) { + if (!namespaceSelector || !('selectedIndex' in namespaceSelector)) { + return; + } + const selectedNamespace = namespaceSelector.options[namespaceSelector.selectedIndex]; + const { name, visibility, visibilityLevel, showPath, editPath } = selectedNamespace.dataset; + + document.querySelectorAll('.visibility-level-setting .radio').forEach((option) => { + const optionInput = option.querySelector('input[type=radio]'); + const optionValue = optionInput ? optionInput.value : 0; + const optionTitle = option.querySelector('.option-title'); + const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : ''; + + // don't change anything if the option is restricted by admin + if (!option.classList.contains('restricted')) { + if (visibilityLevel < optionValue) { + option.classList.add('disabled'); + optionInput.disabled = true; + const reason = option.querySelector('.option-disabled-reason'); + if (reason) { + reason.innerHTML = + `This project cannot be ${optionName} because the visibility of + <a href="${showPath}">${name}</a> is ${visibility}. To make this project + ${optionName}, you must first <a href="${editPath}">change the visibility</a> + of the parent group.`; + } + } else { + option.classList.remove('disabled'); + optionInput.disabled = false; + } + } + }); +} + +export default function initProjectVisibilitySelector() { + const namespaceSelector = document.querySelector('select.js-select-namespace'); + if (namespaceSelector) { + $('.select2.js-select-namespace').on('change', () => setVisibilityOptions(namespaceSelector)); + setVisibilityOptions(namespaceSelector); + } +} diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue new file mode 100644 index 00000000000..7606605be32 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/app.vue @@ -0,0 +1,157 @@ +<script> +import bs from '../../breakpoints'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +import projectsListFrequent from './projects_list_frequent.vue'; +import projectsListSearch from './projects_list_search.vue'; + +import search from './search.vue'; + +export default { + components: { + search, + loadingIcon, + projectsListFrequent, + projectsListSearch, + }, + props: { + currentProject: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + }, + data() { + return { + isLoadingProjects: false, + isFrequentsListVisible: false, + isSearchListVisible: false, + isLocalStorageFailed: false, + isSearchFailed: false, + searchQuery: '', + }; + }, + computed: { + frequentProjects() { + return this.store.getFrequentProjects(); + }, + searchProjects() { + return this.store.getSearchedProjects(); + }, + }, + methods: { + toggleFrequentProjectsList(state) { + this.isLoadingProjects = !state; + this.isSearchListVisible = !state; + this.isFrequentsListVisible = state; + }, + toggleSearchProjectsList(state) { + this.isLoadingProjects = !state; + this.isFrequentsListVisible = !state; + this.isSearchListVisible = state; + }, + toggleLoader(state) { + this.isFrequentsListVisible = !state; + this.isSearchListVisible = !state; + this.isLoadingProjects = state; + }, + fetchFrequentProjects() { + const screenSize = bs.getBreakpointSize(); + if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) { + this.toggleSearchProjectsList(true); + } else { + this.toggleLoader(true); + this.isLocalStorageFailed = false; + const projects = this.service.getFrequentProjects(); + if (projects) { + this.toggleFrequentProjectsList(true); + this.store.setFrequentProjects(projects); + } else { + this.isLocalStorageFailed = true; + this.toggleFrequentProjectsList(true); + this.store.setFrequentProjects([]); + } + } + }, + fetchSearchedProjects(searchQuery) { + this.searchQuery = searchQuery; + this.toggleLoader(true); + this.service.getSearchedProjects(this.searchQuery) + .then(res => res.json()) + .then((results) => { + this.toggleSearchProjectsList(true); + this.store.setSearchedProjects(results); + }) + .catch(() => { + this.isSearchFailed = true; + this.toggleSearchProjectsList(true); + }); + }, + logCurrentProjectAccess() { + this.service.logProjectAccess(this.currentProject); + }, + handleSearchClear() { + this.searchQuery = ''; + this.toggleFrequentProjectsList(true); + this.store.clearSearchedProjects(); + }, + handleSearchFailure() { + this.isSearchFailed = true; + this.toggleSearchProjectsList(true); + }, + }, + created() { + if (this.currentProject.id) { + this.logCurrentProjectAccess(); + } + + eventHub.$on('dropdownOpen', this.fetchFrequentProjects); + eventHub.$on('searchProjects', this.fetchSearchedProjects); + eventHub.$on('searchCleared', this.handleSearchClear); + eventHub.$on('searchFailed', this.handleSearchFailure); + }, + beforeDestroy() { + eventHub.$off('dropdownOpen', this.fetchFrequentProjects); + eventHub.$off('searchProjects', this.fetchSearchedProjects); + eventHub.$off('searchCleared', this.handleSearchClear); + eventHub.$off('searchFailed', this.handleSearchFailure); + }, +}; +</script> + +<template> + <div> + <search/> + <loading-icon + class="loading-animation prepend-top-20" + size="2" + v-if="isLoadingProjects" + :label="s__('ProjectsDropdown|Loading projects')" + /> + <div + class="section-header" + v-if="isFrequentsListVisible" + > + {{ s__('ProjectsDropdown|Frequently visited') }} + </div> + <projects-list-frequent + v-if="isFrequentsListVisible" + :local-storage-failed="isLocalStorageFailed" + :projects="frequentProjects" + /> + <projects-list-search + v-if="isSearchListVisible" + :search-failed="isSearchFailed" + :matcher="searchQuery" + :projects="searchProjects" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue new file mode 100644 index 00000000000..093554cd0bc --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue @@ -0,0 +1,57 @@ +<script> +import { s__ } from '../../locale'; +import projectsListItem from './projects_list_item.vue'; + +export default { + components: { + projectsListItem, + }, + props: { + projects: { + type: Array, + required: true, + }, + localStorageFailed: { + type: Boolean, + required: true, + }, + }, + computed: { + isListEmpty() { + return this.projects.length === 0; + }, + listEmptyMessage() { + return this.localStorageFailed ? + s__('ProjectsDropdown|This feature requires browser localStorage support') : + s__('ProjectsDropdown|Projects you visit often will appear here'); + }, + }, +}; +</script> + +<template> + <div + class="projects-list-frequent-container" + > + <ul + class="list-unstyled" + > + <li + class="section-empty" + v-if="isListEmpty" + > + {{listEmptyMessage}} + </li> + <projects-list-item + v-else + v-for="(project, index) in projects" + :key="index" + :project-id="project.id" + :project-name="project.name" + :namespace="project.namespace" + :web-url="project.webUrl" + :avatar-url="project.avatarUrl" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue new file mode 100644 index 00000000000..fe5179de206 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue @@ -0,0 +1,96 @@ +<script> +import identicon from '../../vue_shared/components/identicon.vue'; + +export default { + components: { + identicon, + }, + props: { + matcher: { + type: String, + required: false, + }, + projectId: { + type: Number, + required: true, + }, + projectName: { + type: String, + required: true, + }, + namespace: { + type: String, + required: true, + }, + webUrl: { + type: String, + required: true, + }, + avatarUrl: { + required: true, + validator(value) { + return value === null || typeof value === 'string'; + }, + }, + }, + computed: { + hasAvatar() { + return this.avatarUrl !== null; + }, + highlightedProjectName() { + if (this.matcher) { + const matcherRegEx = new RegExp(this.matcher, 'gi'); + const matches = this.projectName.match(matcherRegEx); + + if (matches && matches.length > 0) { + return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`); + } + } + return this.projectName; + }, + }, +}; +</script> + +<template> + <li + class="projects-list-item-container" + > + <a + class="clearfix" + :href="webUrl" + > + <div + class="project-item-avatar-container" + > + <img + v-if="hasAvatar" + class="avatar s32" + :src="avatarUrl" + /> + <identicon + v-else + size-class="s32" + :entity-id=projectId + :entity-name="projectName" + /> + </div> + <div + class="project-item-metadata-container" + > + <div + class="project-title" + :title="projectName" + v-html="highlightedProjectName" + > + </div> + <div + class="project-namespace" + :title="namespace" + > + {{namespace}} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue new file mode 100644 index 00000000000..fa5efef2919 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue @@ -0,0 +1,63 @@ +<script> +import { s__ } from '../../locale'; +import projectsListItem from './projects_list_item.vue'; + +export default { + components: { + projectsListItem, + }, + props: { + matcher: { + type: String, + required: true, + }, + projects: { + type: Array, + required: true, + }, + searchFailed: { + type: Boolean, + required: true, + }, + }, + computed: { + isListEmpty() { + return this.projects.length === 0; + }, + listEmptyMessage() { + return this.searchFailed ? + s__('ProjectsDropdown|Something went wrong on our end.') : + s__('ProjectsDropdown|No projects matched your query'); + }, + }, +}; +</script> + +<template> + <div + class="projects-list-search-container" + > + <ul + class="list-unstyled" + > + <li + v-if="isListEmpty" + :class="{ 'section-failure': searchFailed }" + class="section-empty" + > + {{ listEmptyMessage }} + </li> + <projects-list-item + v-else + v-for="(project, index) in projects" + :key="index" + :project-id="project.id" + :project-name="project.name" + :namespace="project.namespace" + :web-url="project.webUrl" + :avatar-url="project.avatarUrl" + :matcher="matcher" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue new file mode 100644 index 00000000000..b71997234e5 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/search.vue @@ -0,0 +1,64 @@ +<script> +import _ from 'underscore'; +import eventHub from '../event_hub'; + +export default { + data() { + return { + searchQuery: '', + }; + }, + watch: { + searchQuery() { + this.handleInput(); + }, + }, + methods: { + setFocus() { + this.$refs.search.focus(); + }, + emitSearchEvents() { + if (this.searchQuery) { + eventHub.$emit('searchProjects', this.searchQuery); + } else { + eventHub.$emit('searchCleared'); + } + }, + /** + * Callback function within _.debounce is intentionally + * kept as ES5 `function() {}` instead of ES6 `() => {}` + * as it otherwise messes up function context + * and component reference is no longer accessible via `this` + */ + // eslint-disable-next-line func-names + handleInput: _.debounce(function () { + this.emitSearchEvents(); + }, 500), + }, + mounted() { + eventHub.$on('dropdownOpen', this.setFocus); + }, + beforeDestroy() { + eventHub.$off('dropdownOpen', this.setFocus); + }, +}; +</script> + +<template> + <div + class="search-input-container hidden-xs" + > + <input + type="search" + class="form-control" + ref="search" + v-model="searchQuery" + :placeholder="s__('ProjectsDropdown|Search projects')" + /> + <i + v-if="!searchQuery" + class="search-icon fa fa-fw fa-search" + aria-hidden="true" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js new file mode 100644 index 00000000000..8937097184c --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/constants.js @@ -0,0 +1,10 @@ +export const FREQUENT_PROJECTS = { + MAX_COUNT: 20, + LIST_COUNT_DESKTOP: 5, + LIST_COUNT_MOBILE: 3, + ELIGIBLE_FREQUENCY: 3, +}; + +export const HOUR_IN_MS = 3600000; + +export const STORAGE_KEY = 'frequent-projects'; diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/projects_dropdown/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js new file mode 100644 index 00000000000..2660da3c558 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/index.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; + +import Translate from '../vue_shared/translate'; +import eventHub from './event_hub'; +import ProjectsService from './service/projects_service'; +import ProjectsStore from './store/projects_store'; + +import projectsDropdownApp from './components/app.vue'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-projects-dropdown'); + const navEl = document.getElementById('nav-projects-dropdown'); + + // Don't do anything if element doesn't exist (No projects dropdown) + // This is for when the user accesses GitLab without logging in + if (!el || !navEl) { + return; + } + + $(navEl).on('show.bs.dropdown', (e) => { + const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu'); + dropdownEl.one('transitionend', () => { + eventHub.$emit('dropdownOpen'); + }); + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + projectsDropdownApp, + }, + data() { + const dataset = this.$options.el.dataset; + const store = new ProjectsStore(); + const service = new ProjectsService(dataset.userName); + + const project = { + id: Number(dataset.projectId), + name: dataset.projectName, + namespace: dataset.projectNamespace, + webUrl: dataset.projectWebUrl, + avatarUrl: dataset.projectAvatarUrl || null, + lastAccessedOn: Date.now(), + }; + + return { + store, + service, + state: store.state, + currentUserName: dataset.userName, + currentProject: project, + }; + }, + render(createElement) { + return createElement('projects-dropdown-app', { + props: { + currentUserName: this.currentUserName, + currentProject: this.currentProject, + store: this.store, + service: this.service, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js new file mode 100644 index 00000000000..fad956b4c26 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js @@ -0,0 +1,132 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +import bp from '../../breakpoints'; +import Api from '../../api'; +import AccessorUtilities from '../../lib/utils/accessor'; + +import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants'; + +Vue.use(VueResource); + +export default class ProjectsService { + constructor(currentUserName) { + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + this.currentUserName = currentUserName; + this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`; + this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath)); + } + + getSearchedProjects(searchQuery) { + return this.projectsPath.get({ + simple: false, + per_page: 20, + membership: !!gon.current_user_id, + order_by: 'last_activity_at', + search: searchQuery, + }); + } + + getFrequentProjects() { + if (this.isLocalStorageAvailable) { + return this.getTopFrequentProjects(); + } + return null; + } + + logProjectAccess(project) { + let matchFound = false; + let storedFrequentProjects; + + if (this.isLocalStorageAvailable) { + const storedRawProjects = localStorage.getItem(this.storageKey); + + // Check if there's any frequent projects list set + if (!storedRawProjects) { + // No frequent projects list set, set one up. + storedFrequentProjects = []; + storedFrequentProjects.push({ ...project, frequency: 1 }); + } else { + // Check if project is already present in frequents list + // When found, update metadata of it. + storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => { + if (projectItem.id === project.id) { + matchFound = true; + const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS; + const updatedProject = { + ...project, + frequency: projectItem.frequency, + lastAccessedOn: projectItem.lastAccessedOn, + }; + + // Check if duration since last access of this project + // is over an hour + if (diff > 1) { + return { + ...updatedProject, + frequency: updatedProject.frequency + 1, + lastAccessedOn: Date.now(), + }; + } + + return { + ...updatedProject, + }; + } + + return projectItem; + }); + + // Check whether currently logged project is present in frequents list + if (!matchFound) { + // We always keep size of frequents collection to 20 projects + // out of which only 5 projects with + // highest value of `frequency` and most recent `lastAccessedOn` + // are shown in projects dropdown + if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) { + storedFrequentProjects.shift(); // Remove an item from head of array + } + + storedFrequentProjects.push({ ...project, frequency: 1 }); + } + } + + localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects)); + } + } + + getTopFrequentProjects() { + const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey)); + let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP; + + if (!storedFrequentProjects) { + return []; + } + + if (bp.getBreakpointSize() === 'sm' || + bp.getBreakpointSize() === 'xs') { + frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE; + } + + const frequentProjects = storedFrequentProjects + .filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY); + + // Sort all frequent projects in decending order of frequency + // and then by lastAccessedOn with recent most first + frequentProjects.sort((projectA, projectB) => { + if (projectA.frequency < projectB.frequency) { + return 1; + } else if (projectA.frequency > projectB.frequency) { + return -1; + } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) { + return 1; + } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) { + return -1; + } + + return 0; + }); + + return _.first(frequentProjects, frequentProjectsCount); + } +} diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js new file mode 100644 index 00000000000..ffefbe693f4 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/store/projects_store.js @@ -0,0 +1,33 @@ +export default class ProjectsStore { + constructor() { + this.state = {}; + this.state.frequentProjects = []; + this.state.searchedProjects = []; + } + + setFrequentProjects(rawProjects) { + this.state.frequentProjects = rawProjects; + } + + getFrequentProjects() { + return this.state.frequentProjects; + } + + setSearchedProjects(rawProjects) { + this.state.searchedProjects = rawProjects.map(rawProject => ({ + id: rawProject.id, + name: rawProject.name, + namespace: rawProject.name_with_namespace, + webUrl: rawProject.web_url, + avatarUrl: rawProject.avatar_url, + })); + } + + getSearchedProjects() { + return this.state.searchedProjects; + } + + clearSearchedProjects() { + this.state.searchedProjects = []; + } +} diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index fa958d75fa4..4c87d46c96e 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -157,11 +157,16 @@ import SidebarHeightManager from './sidebar_height_manager'; Sidebar.prototype.openDropdown = function(blockOrName) { var $block; $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; - $block.find('.edit-link').trigger('click'); if (!this.isOpen()) { this.setCollapseAfterUpdate($block); - return this.toggleSidebar('open'); + this.toggleSidebar('open'); } + + // Wait for the sidebar to trigger('click') open + // so it doesn't cause our dropdown to close preemptively + setTimeout(() => { + $block.find('.js-sidebar-dropdown-toggle').trigger('click'); + }); }; Sidebar.prototype.setCollapseAfterUpdate = function($block) { diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 0be141eb5f9..78b257bf192 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -20,7 +20,7 @@ import './shortcuts_navigation'; Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone')); Mousetrap.bind('r', (function(_this) { return function() { - _this.replyWithSelectedText(); + _this.replyWithSelectedText(isMergeRequest); return false; }; })(this)); @@ -38,9 +38,15 @@ import './shortcuts_navigation'; } } - ShortcutsIssuable.prototype.replyWithSelectedText = function() { + ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) { var quote, documentFragment, el, selected, separator; - var replyField = $('.js-main-target-form #note_note'); + let replyField; + + if (isMergeRequest) { + replyField = $('.js-main-target-form #note_note'); + } else { + replyField = $('.js-main-target-form .js-vue-comment-form'); + } documentFragment = window.gl.utils.getSelectedFragment(); if (!documentFragment) { @@ -57,6 +63,7 @@ import './shortcuts_navigation'; quote = _.map(selected.split("\n"), function(val) { return ("> " + val).trim() + "\n"; }); + // If replyField already has some content, add a newline before our quote separator = replyField.val().trim() !== "" && "\n\n" || ''; replyField.val(function(a, current) { @@ -64,7 +71,7 @@ import './shortcuts_navigation'; }); // Trigger autosave - replyField.trigger('input'); + replyField.trigger('input').trigger('change'); // Trigger autosize var event = document.createEvent('Event'); diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js index 5a6e47e566e..77f070d48cc 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js @@ -36,7 +36,7 @@ export default { /> <a v-if="editable" - class="edit-link pull-right" + class="js-sidebar-dropdown-toggle edit-link pull-right" href="#" > Edit diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js index 2d682215cf8..d32fe4abc7d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -6,6 +6,7 @@ import timeTracker from './time_tracker'; import Store from '../../stores/sidebar_store'; import Mediator from '../../sidebar_mediator'; +import eventHub from '../../event_hub'; export default { data() { @@ -20,6 +21,9 @@ export default { methods: { listenForQuickActions() { $(document).on('ajax:success', '.gfm-form', this.quickActionListened); + eventHub.$on('timeTrackingUpdated', (data) => { + this.quickActionListened(null, data); + }); }, quickActionListened(e, data) { const subscribedCommands = ['spend_time', 'time_estimate']; diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js new file mode 100644 index 00000000000..1c15a1b877a --- /dev/null +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -0,0 +1,85 @@ +/* global Flash */ + +function isValidProjectId(id) { + return id > 0; +} + +class SidebarMoveIssue { + constructor(mediator, dropdownToggle, confirmButton) { + this.mediator = mediator; + + this.$dropdownToggle = $(dropdownToggle); + this.$confirmButton = $(confirmButton); + + this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this); + } + + init() { + this.initDropdown(); + this.addEventListeners(); + } + + destroy() { + this.removeEventListeners(); + } + + initDropdown() { + this.$dropdownToggle.glDropdown({ + search: { + fields: ['name_with_namespace'], + }, + showMenuAbove: true, + selectable: true, + filterable: true, + filterRemote: true, + multiSelect: false, + // Keep the dropdown open after selecting an option + shouldPropagate: false, + data: (searchTerm, callback) => { + this.mediator.fetchAutocompleteProjects(searchTerm) + .then(callback) + .catch(() => new Flash('An error occured while fetching projects autocomplete.')); + }, + renderRow: project => ` + <li> + <a href="#" class="js-move-issue-dropdown-item"> + ${project.name_with_namespace} + </a> + </li> + `, + clicked: (options) => { + const project = options.selectedObj; + const selectedProjectId = options.isMarking ? project.id : 0; + this.mediator.setMoveToProjectId(selectedProjectId); + + this.$confirmButton.attr('disabled', !isValidProjectId(selectedProjectId)); + }, + }); + } + + addEventListeners() { + this.$confirmButton.on('click', this.onConfirmClickedWrapper); + } + + removeEventListeners() { + this.$confirmButton.off('click', this.onConfirmClickedWrapper); + } + + onConfirmClicked() { + if (isValidProjectId(this.mediator.store.moveToProjectId)) { + this.$confirmButton + .disable() + .addClass('is-loading'); + + this.mediator.moveIssue() + .catch(() => { + Flash('An error occured while moving the issue.'); + this.$confirmButton + .enable() + .removeClass('is-loading'); + }); + } + } +} + +export default SidebarMoveIssue; diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 5a82d01dc41..604648407a4 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -4,9 +4,11 @@ import VueResource from 'vue-resource'; Vue.use(VueResource); export default class SidebarService { - constructor(endpoint) { + constructor(endpointMap) { if (!SidebarService.singleton) { - this.endpoint = endpoint; + this.endpoint = endpointMap.endpoint; + this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; + this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; SidebarService.singleton = this; } @@ -25,4 +27,18 @@ export default class SidebarService { emulateJSON: true, }); } + + getProjectsAutocomplete(searchTerm) { + return Vue.http.get(this.projectsAutocompleteEndpoint, { + params: { + search: searchTerm, + }, + }); + } + + moveIssue(moveToProjectId) { + return Vue.http.post(this.moveIssueEndpoint, { + move_to_project_id: moveToProjectId, + }); + } } diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 9edded3ead6..3d8972050a9 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; import sidebarAssignees from './components/assignees/sidebar_assignees'; import confidential from './components/confidential/confidential_issue_sidebar.vue'; +import SidebarMoveIssue from './lib/sidebar_move_issue'; import Mediator from './sidebar_mediator'; @@ -31,6 +32,12 @@ function domContentLoaded() { service: mediator.service, }, }).$mount(confidentialEl); + + new SidebarMoveIssue( + mediator, + $('.js-move-issue'), + $('.js-move-issue-confirmation-button'), + ).init(); } new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 721e92221cf..e38a8db4cc5 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -7,7 +7,11 @@ export default class SidebarMediator { constructor(options) { if (!SidebarMediator.singleton) { this.store = new Store(options); - this.service = new Service(options.endpoint); + this.service = new Service({ + endpoint: options.endpoint, + moveIssueEndpoint: options.moveIssueEndpoint, + projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, + }); SidebarMediator.singleton = this; } @@ -26,6 +30,10 @@ export default class SidebarMediator { return this.service.update(field, selected.length === 0 ? [0] : selected); } + setMoveToProjectId(projectId) { + this.store.setMoveToProjectId(projectId); + } + fetch() { this.service.get() .then(response => response.json()) @@ -35,4 +43,23 @@ export default class SidebarMediator { }) .catch(() => new Flash('Error occured when fetching sidebar data')); } + + fetchAutocompleteProjects(searchTerm) { + return this.service.getProjectsAutocomplete(searchTerm) + .then(response => response.json()) + .then((data) => { + this.store.setAutocompleteProjects(data); + return this.store.autocompleteProjects; + }); + } + + moveIssue() { + return this.service.moveIssue(this.store.moveToProjectId) + .then(response => response.json()) + .then((data) => { + if (location.pathname !== data.web_url) { + gl.utils.visitUrl(data.web_url); + } + }); + } } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 3356dd0191f..cc04a2a3fcf 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -13,6 +13,8 @@ export default class SidebarStore { this.isFetching = { assignees: true, }; + this.autocompleteProjects = []; + this.moveToProjectId = 0; SidebarStore.singleton = this; } @@ -53,4 +55,12 @@ export default class SidebarStore { removeAllAssignees() { this.assignees = []; } + + setAutocompleteProjects(projects) { + this.autocompleteProjects = projects; + } + + setMoveToProjectId(moveToProjectId) { + this.moveToProjectId = moveToProjectId; + } } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js index c05a76a3b4a..aaca42e3ebc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -75,18 +75,20 @@ export default { class="btn btn-small inline"> Check out branch </a> - <span class="dropdown inline prepend-left-10"> + <span class="dropdown prepend-left-10"> <a - class="btn btn-xs dropdown-toggle" + class="btn btn-small inline dropdown-toggle" data-toggle="dropdown" aria-label="Download as" role="button"> <i class="fa fa-download" - aria-hidden="true" /> + aria-hidden="true"> + </i> <i class="fa fa-caret-down" - aria-hidden="true" /> + aria-hidden="true"> + </i> </a> <ul class="dropdown-menu dropdown-menu-align-right"> <li> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 262584769e0..50d14282cad 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -1,6 +1,7 @@ <script> import commitIconSvg from 'icons/_icon_commit.svg'; import userAvatarLink from './user_avatar/user_avatar_link.vue'; + import tooltip from '../directives/tooltip'; export default { props: { @@ -100,17 +101,22 @@ this.author.username ? `${this.author.username}'s avatar` : null; }, }, - data() { - return { commitIconSvg }; + directives: { + tooltip, }, components: { userAvatarLink, }, + created() { + this.commitIconSvg = commitIconSvg; + }, }; </script> <template> <div class="branch-commit"> - <div v-if="hasCommitRef" class="icon-container hidden-xs"> + <div + v-if="hasCommitRef" + class="icon-container hidden-xs"> <i v-if="tag" class="fa fa-tag" @@ -126,7 +132,10 @@ <a v-if="hasCommitRef" class="ref-name hidden-xs" - :href="commitRef.ref_url"> + :href="commitRef.ref_url" + v-tooltip + data-container="body" + :title="commitRef.name"> {{commitRef.name}} </a> @@ -153,7 +162,8 @@ :img-alt="userImageAltDescription" :tooltip-text="author.username" /> - <a class="commit-row-message" + <a + class="commit-row-message" :href="commitUrl"> {{title}} </a> diff --git a/app/assets/javascripts/groups/components/group_identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue index 0edd820743f..7cf2e029cf6 100644 --- a/app/assets/javascripts/groups/components/group_identicon.vue +++ b/app/assets/javascripts/vue_shared/components/identicon.vue @@ -9,6 +9,11 @@ export default { type: String, required: true, }, + sizeClass: { + type: String, + required: false, + default: 's40', + }, }, computed: { /** @@ -38,7 +43,8 @@ export default { <template> <div - class="avatar s40 identicon" + class="avatar identicon" + :class="sizeClass" :style="identiconStyles"> {{identiconTitle}} </div> diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue new file mode 100644 index 00000000000..397d16331d5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue @@ -0,0 +1,16 @@ +<script> + export default { + name: 'confidentialIssueWarning', + }; +</script> +<template> + <div class="confidential-issue-warning"> + <i + aria-hidden="true" + class="fa fa-eye-slash"> + </i> + <span> + This is a confidential issue. Your comment will not be visible to the public. + </span> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 4e10bbc7408..759d30c9c7c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -5,19 +5,30 @@ export default { props: { - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: false, default: '', }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, + addSpacingClasses: { + type: Boolean, + required: false, + default: true, + }, + quickActionsDocsPath: { + type: String, + required: false, + }, }, data() { return { markdownPreview: '', + referencedCommands: '', + referencedUsers: '', markdownPreviewLoading: false, previewMarkdown: false, }; @@ -26,35 +37,48 @@ markdownHeader, markdownToolbar, }, + computed: { + shouldShowReferencedUsers() { + const referencedUsersThreshold = 10; + return this.referencedUsers.length >= referencedUsersThreshold; + }, + }, methods: { toggleMarkdownPreview() { this.previewMarkdown = !this.previewMarkdown; + /* + Can't use `$refs` as the component is technically in the parent component + so we access the VNode & then get the element + */ + const text = this.$slots.textarea[0].elm.value; + if (!this.previewMarkdown) { this.markdownPreview = ''; - } else { + } else if (text) { this.markdownPreviewLoading = true; - this.$http.post( - this.markdownPreviewUrl, - { - /* - Can't use `$refs` as the component is technically in the parent component - so we access the VNode & then get the element - */ - text: this.$slots.textarea[0].elm.value, - }, - ) - .then(resp => resp.json()) - .then((data) => { - this.markdownPreviewLoading = false; - this.markdownPreview = data.body; + this.$http.post(this.markdownPreviewPath, { text }) + .then(resp => resp.json()) + .then((data) => { + this.renderMarkdown(data); + }) + .catch(() => new Flash('Error loading markdown preview')); + } else { + this.renderMarkdown(); + } + }, + renderMarkdown(data = {}) { + this.markdownPreviewLoading = false; + this.markdownPreview = data.body || 'Nothing to preview.'; - this.$nextTick(() => { - $(this.$refs['markdown-preview']).renderGFM(); - }); - }) - .catch(() => new Flash('Error loading markdown preview')); + if (data.references) { + this.referencedCommands = data.references.commands; + this.referencedUsers = data.references.users; } + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); }, }, mounted() { @@ -74,7 +98,8 @@ <template> <div - class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" + class="md-area js-vue-markdown-field" + :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }" ref="gl-form"> <markdown-header :preview-markdown="previewMarkdown" @@ -94,7 +119,9 @@ </i> </a> <markdown-toolbar - :markdown-docs="markdownDocs" /> + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + /> </div> </div> <div @@ -108,5 +135,27 @@ Loading... </span> </div> + <template v-if="previewMarkdown && !markdownPreviewLoading"> + <div + v-if="referencedCommands" + v-html="referencedCommands" + class="referenced-commands"></div> + <div + v-if="shouldShowReferencedUsers" + class="referenced-users"> + <span> + <i + class="fa fa-exclamation-triangle" + aria-hidden="true"> + </i> + You are about to add + <strong> + <span class="js-referenced-users-count"> + {{referencedUsers.length}} + </span> + </strong> people to the discussion. Proceed with caution. + </span> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 93252293ba6..65fe7bbd94e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,10 +1,14 @@ <script> export default { props: { - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, + quickActionsDocsPath: { + type: String, + required: false, + }, }, }; </script> @@ -12,22 +16,77 @@ <template> <div class="comment-toolbar clearfix"> <div class="toolbar-text"> - <a - :href="markdownDocs" - target="_blank" - tabindex="-1"> - Markdown is supported - </a> + <template v-if="!quickActionsDocsPath && markdownDocsPath"> + <a + :href="markdownDocsPath" + target="_blank" + tabindex="-1"> + Markdown is supported + </a> + </template> + <template v-if="quickActionsDocsPath && markdownDocsPath"> + <a + :href="markdownDocsPath" + target="_blank" + tabindex="-1"> + Markdown + </a> + and + <a + :href="quickActionsDocsPath" + target="_blank" + tabindex="-1"> + quick actions + </a> + are supported + </template> </div> - <button - class="toolbar-button markdown-selector" - type="button" - tabindex="-1"> - <i - class="fa fa-file-image-o toolbar-button-icon" - aria-hidden="true"> - </i> - Attach a file - </button> + <span class="uploading-container"> + <span class="uploading-progress-container hide"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + <span class="attaching-file-message"></span> + <span class="uploading-progress">0%</span> + <span class="uploading-spinner"> + <i + class="fa fa-spinner fa-spin toolbar-button-icon" + aria-hidden="true"></i> + </span> + </span> + <span class="uploading-error-container hide"> + <span class="uploading-error-icon"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + </span> + <span class="uploading-error-message"></span> + <button + class="retry-uploading-link" + type="button"> + Try again + </button> + or + <button + class="attach-new-file markdown-selector" + type="button"> + attach a new file + </button> + </span> + <button + class="markdown-selector button-attach-file" + tabindex="-1" + type="button"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + Attach a file + </button> + <button + class="btn btn-default btn-xs hide button-cancel-uploading-files" + type="button"> + Cancel + </button> + </span> </div> </template> diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index b2b3297e880..c0524bf6aa3 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -51,3 +51,4 @@ @import "framework/snippets"; @import "framework/memory_graph"; @import "framework/responsive-tables"; +@import "framework/feature_highlight"; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index b4a6b214e98..82350c36df0 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -46,6 +46,15 @@ } } +@mixin btn-svg { + svg { + height: 15px; + width: 15px; + position: relative; + top: 2px; + } +} + @mixin btn-color($light, $border-light, $normal, $border-normal, $dark, $border-dark, $color) { background-color: $light; border-color: $border-light; @@ -123,6 +132,7 @@ .btn { @include btn-default; @include btn-white; + @include btn-svg; color: $gl-text-color; @@ -222,13 +232,6 @@ } } - svg { - height: 15px; - width: 15px; - position: relative; - top: 2px; - } - svg, .fa { &:not(:last-child) { diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 4ce767e4cc4..c165ec0b94b 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -95,8 +95,8 @@ .is-selected .pika-day, .pika-day:hover, .is-today .pika-day { - background: $gl-primary; - color: $white-light; + background: $gray-darker; + color: $gl-text-color; box-shadow: none; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index e16fbbf43b5..a85051642dd 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -16,10 +16,12 @@ .prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left: 20px; } .append-right-5 { margin-right: 5px; } +.append-right-8 { margin-right: 8px; } .append-right-10 { margin-right: 10px; } .append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right: 20px; } .append-bottom-0 { margin-bottom: 0; } +.append-bottom-5 { margin-bottom: 5px; } .append-bottom-10 { margin-bottom: 10px; } .append-bottom-15 { margin-bottom: 15px; } .append-bottom-20 { margin-bottom: 20px; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index a45d5a6dca0..6b21def33a6 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -189,11 +189,11 @@ width: auto; top: 100%; left: 0; - z-index: 9; + z-index: 200; min-width: 240px; max-width: 500px; margin-top: 2px; - margin-bottom: 0; + margin-bottom: 2px; font-size: 14px; font-weight: $gl-font-weight-normal; padding: 8px 0; @@ -368,6 +368,10 @@ transform: translateY(0); } +.comment-type-dropdown.open .dropdown-menu { + display: block; +} + .filtered-search-box-input-container { .dropdown-menu, .dropdown-menu-nav { @@ -618,6 +622,11 @@ border-top: 1px solid $dropdown-divider-color; } +.dropdown-footer-content { + padding-left: 10px; + padding-right: 10px; +} + .dropdown-due-date-footer { padding-top: 0; margin-left: 10px; @@ -728,7 +737,10 @@ @mixin new-style-dropdown($selector: '') { #{$selector}.dropdown-menu, #{$selector}.dropdown-menu-nav { + margin-bottom: 24px; + li { + display: block; padding: 0 1px; &:hover { @@ -748,13 +760,18 @@ } a, - button { + button, + .menu-item { border-radius: 0; + box-shadow: none; padding: 8px 16px; + text-align: left; + white-space: normal; + width: 100%; // make sure the text color is not overriden &.text-danger { - @extend .text-danger; + color: $brand-danger; } &.is-focused, @@ -763,6 +780,11 @@ &:focus { background-color: $dropdown-item-hover-bg; color: $gl-text-color; + + // make sure the text color is not overriden + &.text-danger { + color: $brand-danger; + } } &.is-active { @@ -771,6 +793,11 @@ &::before { top: 16px; } + + &.dropdown-menu-user-link::before { + top: 50%; + transform: translateY(-50%); + } } } } @@ -791,4 +818,164 @@ #{$selector}.dropdown-menu-align-right { margin-top: 2px; } + + .open { + #{$selector}.dropdown-menu, + #{$selector}.dropdown-menu-nav { + @media (max-width: $screen-xs-max) { + max-width: 100%; + } + } + } +} + +@include new-style-dropdown('.js-namespace-select + '); + +header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu { + padding: 0; + + @media (max-width: $screen-xs-max) { + display: table; + left: -50px; + min-width: 300px; + } +} + +.projects-dropdown-container { + display: flex; + flex-direction: row; + width: 500px; + height: 334px; + + .project-dropdown-sidebar, + .project-dropdown-content { + padding: 8px 0; + } + + .loading-animation { + color: $almost-black; + } + + .project-dropdown-sidebar { + width: 30%; + border-right: 1px solid $border-color; + } + + .project-dropdown-content { + position: relative; + width: 70%; + } + + @media (max-width: $screen-xs-max) { + flex-direction: column; + width: 100%; + height: auto; + flex: 1; + + .project-dropdown-sidebar, + .project-dropdown-content { + width: 100%; + } + + .project-dropdown-sidebar { + border-bottom: 1px solid $border-color; + border-right: 0; + } + } +} + +.projects-dropdown-container { + .projects-list-frequent-container, + .projects-list-search-container, { + padding: 8px 0; + overflow-y: auto; + } + + .section-header, + .projects-list-frequent-container li.section-empty, + .projects-list-search-container li.section-empty { + padding: 0 15px; + } + + .section-header, + .projects-list-frequent-container li.section-empty, + .projects-list-search-container li.section-empty { + color: $gl-text-color-secondary; + font-size: $gl-font-size; + } + + .projects-list-frequent-container, + .projects-list-search-container { + li.section-empty.section-failure { + color: $callout-danger-color; + } + } + + .search-input-container { + position: relative; + padding: 4px $gl-padding; + + .search-icon { + position: absolute; + top: 13px; + right: 25px; + color: $md-area-border; + } + } + + .section-header { + font-weight: 700; + margin-top: 8px; + } + + .projects-list-search-container { + height: 284px; + } + + @media (max-width: $screen-xs-max) { + .projects-list-frequent-container { + width: auto; + height: auto; + padding-bottom: 0; + } + } +} + +.projects-list-item-container { + .project-item-avatar-container + .project-item-metadata-container { + float: left; + } + + .project-title, + .project-namespace { + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + .project-item-avatar-container .avatar { + border-color: $md-area-border; + } + } + + .project-title { + font-size: $gl-font-size; + font-weight: 400; + line-height: 16px; + } + + .project-namespace { + margin-top: 4px; + font-size: 12px; + line-height: 12px; + color: $gl-text-color-secondary; + } + + @media (max-width: $screen-xs-max) { + .project-item-metadata-container { + float: none; + } + } } diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss new file mode 100644 index 00000000000..ebae473df50 --- /dev/null +++ b/app/assets/stylesheets/framework/feature_highlight.scss @@ -0,0 +1,94 @@ +.feature-highlight { + position: relative; + margin-left: $gl-padding; + width: 20px; + height: 20px; + cursor: pointer; + + &::before { + content: ''; + display: block; + position: absolute; + top: 6px; + left: 6px; + width: 8px; + height: 8px; + background-color: $blue-500; + border-radius: 50%; + box-shadow: 0 0 0 rgba($blue-500, 0.4); + animation: pulse-highlight 2s infinite; + } + + &:hover::before, + &.disable-animation::before { + animation: none; + } + + &[disabled]::before { + display: none; + } +} + +.is-showing-fly-out { + .feature-highlight { + display: none; + } +} + +.feature-highlight-popover-content { + display: none; + + hr { + margin: $gl-padding * 0.5 0; + } + + .btn-link { + @include btn-svg; + + svg path { + fill: currentColor; + } + } + + .dismiss-feature-highlight { + padding: 0; + } + + svg:first-child { + width: 100%; + background-color: $indigo-50; + border-top-left-radius: 2px; + border-top-right-radius: 2px; + border-bottom: 1px solid darken($gray-normal, 8%); + } +} + +.popover .feature-highlight-popover-content { + display: block; +} + +.feature-highlight-popover { + padding: 0; + + .popover-content { + padding: 0; + } +} + +.feature-highlight-popover-sub-content { + padding: 9px 14px; +} + +@include keyframes(pulse-highlight) { + 0% { + box-shadow: 0 0 0 0 rgba($blue-200, 0.4); + } + + 70% { + box-shadow: 0 0 0 10px transparent; + } + + 100% { + box-shadow: 0 0 0 0 transparent; + } +} diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index a5d33d410fb..b2847c348eb 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -225,6 +225,18 @@ color: $common-gray-dark; } + gl-emoji { + display: inline-block; + font-family: inherit; + font-size: inherit; + vertical-align: inherit; + + img { + height: 18px; + width: 18px; + } + } + .form-control { position: relative; min-width: 200px; @@ -277,7 +289,7 @@ } .filtered-search-input-dropdown-menu { - max-height: 225px; + max-height: 260px; max-width: 280px; overflow: auto; @@ -478,3 +490,7 @@ padding: 8px 16px; text-align: center; } + +.issues-details-filters { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 35bd97980e2..b00a2d053e2 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -105,12 +105,11 @@ header { top: -3px; font-size: 10px; } + } + .user-counter { svg { - position: relative; - top: 2px; - height: 17px; - // hack to get SVG to line up with FA icons + height: 16px; width: 23px; fill: currentColor; } @@ -325,12 +324,12 @@ header { li { .badge { position: inherit; - top: -8px; font-weight: $gl-font-weight-normal; - margin-left: -11px; + margin-left: -6px; font-size: 11px; color: $white-light; - padding: 1px 5px 2px; + padding: 0 5px; + line-height: 12px; border-radius: 7px; box-shadow: 0 1px 0 rgba($gl-header-color, .2); diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index d93722e2174..6c14e8b97e0 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -266,11 +266,27 @@ } // TODO: change global style -.ajax-project-dropdown { +.ajax-project-dropdown, +.ajax-users-dropdown, +body[data-page="projects:edit"] #select2-drop, +body[data-page="projects:new"] #select2-drop, +body[data-page="projects:merge_requests:edit"] #select2-drop, +body[data-page="projects:blob:new"] #select2-drop, +body[data-page="profiles:show"] #select2-drop, +body[data-page="admin:groups:show"] #select2-drop, +body[data-page="projects:issues:show"] #select2-drop, +body[data-page="projects:blob:edit"] #select2-drop { &.select2-drop { + border: 1px solid $dropdown-border-color; + border-radius: $border-radius-base; color: $gl-text-color; } + &.select2-drop-above { + border-top: none; + margin-top: -4px; + } + .select2-results { .select2-no-results, .select2-searching, diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 40e8a928e6e..ef58382ba41 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -132,3 +132,7 @@ width: calc(100% + 35px); } } + +.issuable-sidebar { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 71eec0e1a5e..3c0b4c82d19 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -10,8 +10,7 @@ color: $md-link-color; } - img { - /*max-width: 100%;*/ + img:not(.emoji) { margin: 0 0 8px; } @@ -26,6 +25,7 @@ min-width: inherit; min-height: inherit; background-color: inherit; + max-width: 100%; } p a:not(.no-attachment-icon) img { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8a2e64f7bf5..88b08998dfd 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -120,6 +120,7 @@ $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: rgba(255, 255, 255, 1.0); $gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); $gl-text-green: $green-600; +$gl-text-green-hover: $green-700; $gl-text-red: $red-500; $gl-text-orange: $orange-600; $gl-link-color: $blue-600; @@ -176,13 +177,14 @@ $row-hover: $blue-25; $row-hover-border: $blue-100; $progress-color: #c0392b; $header-height: 50px; +$new-navbar-height: 40px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; $limited-layout-width-sm: 790px; $container-text-max-width: 540px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; -$border-radius-default: 3px; +$border-radius-default: 4px; $settings-icon-size: 18px; $provider-btn-not-active-color: $blue-500; $link-underline-blue: $blue-500; @@ -590,9 +592,10 @@ $ui-dev-kit-example-border: #ddd; /* Pipeline Graph */ -$stage-hover-bg: #eaf3fc; -$stage-hover-border: #d1e7fc; -$action-icon-color: #d6d6d6; +$stage-hover-bg: $gray-darker; +$ci-action-icon-size: 22px; +$pipeline-dropdown-line-height: 20px; +$pipeline-dropdown-status-icon-size: 18px; /* Pipeline Schedules diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss index 54fa4109f8b..4deb7431284 100644 --- a/app/assets/stylesheets/new_nav.scss +++ b/app/assets/stylesheets/new_nav.scss @@ -2,21 +2,35 @@ @import 'framework/tw_bootstrap_variables'; @import "bootstrap/variables"; +.content-wrapper.page-with-new-nav { + margin-top: $new-navbar-height; +} + header.navbar-gitlab-new { color: $white-light; background: linear-gradient(to right, $indigo-900, $indigo-800); border-bottom: 0; + min-height: $new-navbar-height; .header-content { + display: -webkit-flex; + display: flex; padding-left: 0; + min-height: $new-navbar-height; .title-container { + display: -webkit-flex; + display: flex; + -webkit-align-items: stretch; align-items: stretch; + -webkit-flex: 1 1 auto; + flex: 1 1 auto; padding-top: 0; overflow: visible; } .title { + display: -webkit-flex; display: flex; padding-right: 0; color: currentColor; @@ -27,22 +41,16 @@ header.navbar-gitlab-new { } > a { + display: -webkit-flex; display: flex; align-items: center; - padding-right: $gl-padding; - padding-left: $gl-padding; - margin-left: -$gl-padding; - - @media (min-width: $screen-sm-min) { - padding-right: $gl-padding; - padding-left: $gl-padding; - } + padding: 2px 8px; + margin: 5px 2px 5px -8px; + border-radius: $border-radius-default; svg { - margin-top: -3px; - @media (min-width: $screen-sm-min) { - margin-right: 10px; + margin-right: 8px; } } @@ -51,7 +59,7 @@ header.navbar-gitlab-new { svg { width: 55px; - height: 15px; + height: 14px; margin: 0; fill: $white-light; } @@ -59,9 +67,7 @@ header.navbar-gitlab-new { &:hover, &:focus { - .logo-text svg { - fill: $tanuki-yellow; - } + background-color: rgba($indigo-200, .2); } } } @@ -81,6 +87,20 @@ header.navbar-gitlab-new { right: 0; } } + + &.menu-expanded { + @media (max-width: $screen-xs-max) { + .title-container, + .header-logo, { + display: none; + } + } + } + } + + .dropdown-bold-header { + color: $gl-text-color-secondary; + font-size: 12px; } .navbar-collapse { @@ -89,14 +109,10 @@ header.navbar-gitlab-new { box-shadow: 0; @media (max-width: $screen-xs-max) { - margin-left: -$gl-padding; + margin-left: -8px; margin-right: -10px; } - .dropdown-bold-header { - color: initial; - } - .nav { > li:not(.hidden-xs) a { @media (max-width: $screen-xs-max) { @@ -110,7 +126,7 @@ header.navbar-gitlab-new { .container-fluid { .navbar-toggle { min-width: 45px; - padding: 6px $gl-padding; + padding: 4px $gl-padding; margin-right: -7px; font-size: 14px; text-align: center; @@ -147,76 +163,167 @@ header.navbar-gitlab-new { } > a { - background: none; will-change: color; + margin: 4px 2px; + padding: 6px 8px; + color: $indigo-200; + height: 32px; + + @media (max-width: $screen-xs-max) { + padding: 0; + } + + svg { + fill: $indigo-200; + } &.header-user-dropdown-toggle { + margin-left: 2px; + .header-user-avatar { border-color: $indigo-200; + margin-right: 0; } } + } - &:hover, - &:focus { - color: $white-light; - opacity: 1; + .header-new-dropdown-toggle { + margin-right: 0; + } - > svg { - fill: $white-light; - } + > a:hover, + > a:focus { + text-decoration: none; + outline: 0; + opacity: 1; + color: $white-light; + + @media (min-width: $screen-sm-min) { + background-color: rgba($indigo-200, .2); + } - &.header-user-dropdown-toggle { - .header-user-avatar { - border-color: $white-light; - } + svg { + fill: currentColor; + } + + &.header-user-dropdown-toggle { + .header-user-avatar { + border-color: $white-light; } } } + + .impersonated-user, + .impersonated-user:hover { + margin-right: 1px; + background-color: $white-light; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + svg { + fill: $indigo-900; + } + } + + .impersonation-btn, + .impersonation-btn:hover { + background-color: $white-light; + margin-left: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + i { + color: $orange-500; + font-size: 20px; + } + } + + &.active > a, + &.dropdown.open > a { + color: $indigo-900; + background-color: $white-light; + + svg { + fill: currentColor; + } + } } } } .navbar-sub-nav { + display: -webkit-flex; display: flex; - margin-bottom: 0; + margin: 0 0 0 6px; color: $indigo-200; - > li { - > a:hover, - > a:focus { - box-shadow: inset 0 -3px 0 rgba($indigo-200, .4); - text-decoration: none; - outline: 0; - color: $white-light; - } + .dropdown-chevron { + position: relative; + top: -1px; + font-size: 10px; + } +} - &.active > a { - box-shadow: inset 0 -3px 0 $indigo-500; - color: $white-light; - font-weight: $gl-font-weight-bold; - } +.navbar-gitlab-new { + .navbar-sub-nav, + .navbar-nav { + > li { + > a:hover, + > a:focus { + text-decoration: none; + outline: 0; + color: $white-light; + background-color: rgba($indigo-200, .2); - > a { - display: block; - padding: 16px 10px; - font-size: 13px; - color: currentColor; - box-shadow: inset 0 0 0 transparent; - will-change: box-shadow; - transition: box-shadow 0.15s; + svg { + fill: currentColor; + } + } - @media (min-width: $screen-sm-min) { - padding: 15px $gl-padding; - font-size: 14px; + &.active > a, + &.dropdown.open > a { + color: $indigo-900; + background-color: $white-light; + + svg { + fill: currentColor; + } + } + + > a { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 8px; + margin: 4px 2px; + font-size: 12px; + color: currentColor; + border-radius: $border-radius-default; + height: 32px; + font-weight: $gl-font-weight-bold; + + svg { + fill: currentColor; + } + } + + &.line-separator { + border-left: 1px solid rgba($indigo-200, .2); + margin: 8px; } } } +} - .dropdown-chevron { - position: relative; - top: -1px; - font-size: 10px; - } +.admin-icon i { + font-size: 18px; +} + +.caret-down { + height: 11px; + width: 11px; + margin-left: 4px; + fill: currentColor; } .header-user .dropdown-menu-nav, @@ -225,10 +332,14 @@ header.navbar-gitlab-new { } .search { + margin: 4px 8px 0; + form { + height: 32px; border: 0; + border-radius: $border-radius-default; background-color: rgba($indigo-200, .2); - transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s, background-color ease-in-out 0.15s; + transition: border-color ease-in-out 0.15s, background-color ease-in-out 0.15s; &:hover { background-color: rgba($indigo-200, .3); @@ -237,31 +348,50 @@ header.navbar-gitlab-new { } &.search-active form { - background-color: rgba($indigo-200, .3); + background-color: $white-light; box-shadow: none; + + .search-input { + color: $gl-text-color; + transition: color ease-in-out 0.15s; + } + + .search-input::placeholder { + color: $gl-text-color-tertiary; + } + + .search-input-wrap { + .search-icon, + .clear-icon { + color: $gl-text-color-tertiary; + transition: color ease-in-out 0.15s; + } + } } .search-input { color: $white-light; background: none; + transition: color ease-in-out 0.15s; } .search-input::placeholder { color: rgba($indigo-200, .8); + transition: color ease-in-out 0.15s; } .location-badge { font-size: 12px; color: $indigo-100; background-color: rgba($indigo-200, .1); - transition: color 0.15s; will-change: color; margin: -4px 4px -4px -4px; line-height: 25px; padding: 4px 8px; border-radius: 2px 0 0 2px; border-right: 1px solid $indigo-800; - height: 34px; + height: 32px; + transition: border-color ease-in-out 0.15s; } .search-input-wrap { @@ -273,8 +403,9 @@ header.navbar-gitlab-new { &.search-active { .location-badge { - color: $white-light; - background-color: rgba($indigo-200, .2); + color: $gl-text-color; + background-color: $nav-badge-bg; + border-color: $border-color; } .search-input-wrap { @@ -448,3 +579,14 @@ header.navbar-gitlab-new { } } } + +.btn-sign-in { + margin-top: 3px; + background-color: $indigo-100; + color: $indigo-900; + font-weight: $gl-font-weight-bold; + + &:hover { + background-color: $white-light; + } +} diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss index f624b130e19..90b0a543c5c 100644 --- a/app/assets/stylesheets/new_sidebar.scss +++ b/app/assets/stylesheets/new_sidebar.scss @@ -26,7 +26,7 @@ $new-sidebar-collapsed-width: 50px; // Override position: absolute .right-sidebar { position: fixed; - height: calc(100% - #{$header-height}); + height: calc(100% - #{$new-navbar-height}); } .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { @@ -93,7 +93,7 @@ $new-sidebar-collapsed-width: 50px; z-index: 400; width: $new-sidebar-width; transition: left $sidebar-transition-duration; - top: $header-height; + top: $new-navbar-height; bottom: 0; left: 0; background-color: $gray-normal; @@ -189,7 +189,7 @@ $new-sidebar-collapsed-width: 50px; } .with-performance-bar .nav-sidebar { - top: $header-height + $performance-bar-height; + top: $new-navbar-height + $performance-bar-height; } .sidebar-sub-level-items { @@ -453,7 +453,7 @@ $new-sidebar-collapsed-width: 50px; // Make issue boards full-height now that sub-nav is gone .boards-list { - height: calc(100vh - #{$header-height}); + height: calc(100vh - #{$new-navbar-height}); @media (min-width: $screen-sm-min) { height: 475px; // Needed for PhantomJS @@ -464,7 +464,7 @@ $new-sidebar-collapsed-width: 50px; } .with-performance-bar .boards-list { - height: calc(100vh - #{$header-height} - #{$performance-bar-height}); + height: calc(100vh - #{$new-navbar-height} - #{$performance-bar-height}); } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 3d04df8d820..50ec5110bf1 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -322,14 +322,13 @@ } .build-dropdown { - padding: $gl-padding 0; + @include new-style-dropdown; - .dropdown-menu-toggle { - margin-top: 8px; - } + margin: $gl-padding 0; + padding: 0; - .dropdown-menu { - margin-top: -$gl-padding; + .dropdown-menu-toggle { + margin-top: #{$gl-padding / 2}; } svg { diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index f6b8c8ee2bc..d3cd4d507de 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -204,6 +204,8 @@ .gitlab-ci-yml-selector, .dockerfile-selector, .template-type-selector { + @include new-style-dropdown; + display: inline-block; vertical-align: top; font-family: $regular_font; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index a8d2ae0af28..a52ac0d53e7 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -12,6 +12,8 @@ .environments-container { .ci-table { + @include new-style-dropdown; + .deployment-column { > span { word-break: break-all; @@ -167,7 +169,7 @@ } .metric-area { - opacity: 0.8; + opacity: 0.25; } .prometheus-graph-overlay { @@ -249,8 +251,14 @@ font-weight: $gl-font-weight-bold; } - .label-axis-text, - .text-metric-usage { + .label-axis-text { + fill: $black; + font-weight: $gl-font-weight-normal; + font-size: 10px; + } + + .text-metric-usage, + .legend-metric-title { fill: $black; font-weight: $gl-font-weight-normal; font-size: 12px; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ab5a901da71..9f2cb979518 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -473,7 +473,7 @@ padding-top: 6px; } - .open .dropdown-menu { + .dropdown-menu { width: 100%; } } @@ -486,6 +486,24 @@ } } +.sidebar-move-issue-dropdown { + @include new-style-dropdown; +} + +.sidebar-move-issue-confirmation-button { + width: 100%; + + &.is-loading { + .sidebar-move-issue-confirmation-loading-icon { + display: inline-block; + } + } +} + +.sidebar-move-issue-confirmation-loading-icon { + display: none; +} + .detail-page-description { padding: 16px 0; @@ -498,6 +516,7 @@ color: $gray-darkest; display: block; margin: 16px 0 0; + font-size: 85%; .author_link { color: $gray-darkest; @@ -598,6 +617,8 @@ } .issuable-actions { + @include new-style-dropdown; + padding-top: 10px; @media (min-width: $screen-sm-min) { diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index e2177f96aee..e8ca5cedaee 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -143,8 +143,12 @@ ul.related-merge-requests > li { } } -.issue-form .select2-container { - width: 250px !important; +.issue-form { + @include new-style-dropdown; + + .select2-container { + width: 250px !important; + } } .issues-footer { @@ -186,6 +190,8 @@ ul.related-merge-requests > li { } .create-mr-dropdown-wrap { + @include new-style-dropdown; + .btn-group:not(.hide) { display: flex; } @@ -212,15 +218,6 @@ ul.related-merge-requests > li { } li:not(.divider) { - padding: 6px; - cursor: pointer; - - &:hover, - &:focus { - background-color: $dropdown-hover-color; - color: $white-light; - } - &.droplab-item-selected { .icon-container { i { @@ -250,6 +247,10 @@ ul.related-merge-requests > li { } } +.discussion-reply-holder .note-edit-form { + display: block; +} + @media (min-width: $screen-sm-min) { .emoji-block .row { display: flex; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index ee48f7a3626..443f5500684 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -116,6 +116,8 @@ } .manage-labels-list { + @include new-style-dropdown; + > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; cursor: move; diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index a385eb359e1..b3bab082a35 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -55,6 +55,10 @@ display: -webkit-flex; display: flex; } + + .dropdown-menu.dropdown-menu-align-right { + margin-top: -2px; + } } .form-horizontal { @@ -96,6 +100,8 @@ } .member-search-form { + @include new-style-dropdown; + position: relative; @media (min-width: $screen-sm-min) { @@ -304,3 +310,7 @@ } } } + +.member-form-control { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index d1678a17aaf..8609f72bdab 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -174,17 +174,6 @@ vertical-align: top; } - .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item { - display: flex; - align-items: center; - - .ci-status-text, - .ci-status-icon { - top: 0; - margin-right: 10px; - } - } - .normal { line-height: 28px; } @@ -291,6 +280,7 @@ .dropdown-toggle { .fa { + margin-left: 0; color: inherit; } } @@ -489,6 +479,8 @@ } .mr-source-target { + @include new-style-dropdown; + display: flex; flex-wrap: wrap; justify-content: space-between; @@ -610,6 +602,8 @@ } .mr-version-controls { + @include new-style-dropdown; + position: relative; background: $gray-light; color: $gl-text-color; @@ -727,3 +721,7 @@ font-size: 16px; } } + +.merge-request-form { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 9558924bbcb..5d7c85b16ef 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -20,13 +20,11 @@ } } -.new-note { - display: none; -} - .new-note, .note-edit-form { .note-form-actions { + @include new-style-dropdown; + position: relative; margin: $gl-padding 0 0; } @@ -202,6 +200,10 @@ .discussion-reply-holder { background-color: $white-light; padding: 10px 16px; + + &.is-replying { + padding-bottom: $gl-padding; + } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 0a194f3707f..45f2aed1531 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -100,6 +100,20 @@ ul.notes { } } + .editing-spinner { + display: none; + } + + &.is-requesting { + .note-timestamp { + display: none; + } + + .editing-spinner { + display: inline-block; + } + } + &.is-editing { .note-header, .note-text, @@ -365,9 +379,7 @@ ul.notes { } .discussion-header, -.note-header { - position: relative; - +.note-header-info { a { color: inherit; @@ -402,6 +414,10 @@ ul.notes { .note-header-info { min-width: 0; padding-bottom: 8px; + + &.discussion { + padding-bottom: 0; + } } .system-note .note-header-info { @@ -453,6 +469,8 @@ ul.notes { } .note-actions { + @include new-style-dropdown; + align-self: flex-start; flex-shrink: 0; display: inline-flex; @@ -488,22 +506,6 @@ ul.notes { .more-actions-dropdown { width: 180px; min-width: 180px; - margin-top: $gl-btn-padding; - - li > a, - li > .btn { - color: $gl-text-color; - padding: $gl-btn-padding; - width: 100%; - text-align: left; - - &:hover, - &:focus { - color: $gl-text-color; - background-color: $blue-25; - border-radius: $border-radius-default; - } - } } .discussion-actions { @@ -766,17 +768,25 @@ ul.notes { background-color: transparent; border: none; outline: 0; + transition: color $general-hover-transition-duration $general-hover-transition-curve; &.is-disabled { cursor: default; } - &:not(.is-disabled):hover, + &:not(.is-disabled) { + &:hover, + &:focus { + color: $gl-text-green; + } + } + &.is-active { color: $gl-text-green; - svg { - fill: $gl-text-green; + &:hover, + &:focus { + color: $gl-text-green-hover; } } @@ -806,10 +816,6 @@ ul.notes { } } -.discussion-notes .flash-container { - margin-bottom: 0; -} - // Merge request notes in diffs .diff-file { // Diff is inline diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss index bdf07a99daf..c28b1e68008 100644 --- a/app/assets/stylesheets/pages/notifications.scss +++ b/app/assets/stylesheets/pages/notifications.scss @@ -14,3 +14,7 @@ font-size: 18px; } } + +.notification-form { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a408bde37d6..cb8815e4775 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -40,7 +40,7 @@ .btn.btn-retry:hover, .btn.btn-retry:focus { - border-color: $gray-darkest; + border-color: $dropdown-toggle-active-border-color; background-color: $white-normal; } @@ -206,8 +206,8 @@ .stage-cell { .mini-pipeline-graph-dropdown-toggle svg { - height: 22px; - width: 22px; + height: $ci-action-icon-size; + width: $ci-action-icon-size; position: absolute; top: -1px; left: -1px; @@ -219,7 +219,7 @@ display: inline-block; position: relative; vertical-align: middle; - height: 22px; + height: $ci-action-icon-size; margin: 3px 0; + .stage-container { @@ -257,6 +257,8 @@ // Pipeline visualization .pipeline-actions { + @include new-style-dropdown; + border-bottom: none; } @@ -308,7 +310,7 @@ a { text-decoration: none; - color: $gl-text-color-secondary; + color: $gl-text-color; } svg { @@ -432,7 +434,11 @@ width: 186px; margin-bottom: 10px; white-space: normal; - color: $gl-text-color-secondary; + + // ensure .build-content has hover style when action-icon is hovered + .ci-job-dropdown-container:hover .build-content { + @extend .build-content:hover; + } // Action Icons in big pipeline-graph nodes .ci-action-icon-container .ci-action-icon-wrapper { @@ -445,11 +451,11 @@ &:hover { background-color: $stage-hover-bg; - border: 1px solid $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; } svg { - fill: $border-color; + fill: $gl-text-color-secondary; position: relative; left: -1px; top: -1px; @@ -475,19 +481,10 @@ background-color: transparent; border: none; padding: 0; - color: $gl-text-color-secondary; &:focus { outline: none; } - - &:hover { - color: $gl-text-color; - - .dropdown-counter-badge { - color: $gl-text-color; - } - } } .build-content { @@ -502,8 +499,7 @@ a.build-content:hover, button.build-content:hover { background-color: $stage-hover-bg; - border: 1px solid $stage-hover-border; - color: $gl-text-color; + border: 1px solid $dropdown-toggle-active-border-color; } @@ -564,7 +560,6 @@ // Triggers the dropdown in the big pipeline graph .dropdown-counter-badge { - color: $border-color; font-weight: 100; font-size: 15px; position: absolute; @@ -606,8 +601,8 @@ button.mini-pipeline-graph-dropdown-toggle { background-color: $white-light; border-width: 1px; border-style: solid; - width: 22px; - height: 22px; + width: $ci-action-icon-size; + height: $ci-action-icon-size; margin: 0; padding: 0; transition: all 0.2s linear; @@ -669,105 +664,119 @@ button.mini-pipeline-graph-dropdown-toggle { } } +@include new-style-dropdown('.big-pipeline-graph-dropdown-menu'); +@include new-style-dropdown('.mini-pipeline-graph-dropdown-menu'); + // dropdown content for big and mini pipeline .big-pipeline-graph-dropdown-menu, .mini-pipeline-graph-dropdown-menu { width: 195px; max-width: 195px; - li { - padding: 2px 3px; - } - .scrollable-menu { padding: 0; max-height: 245px; overflow: auto; } - // Action icon on the right - a.ci-action-icon-wrapper { - color: $action-icon-color; - border: 1px solid $action-icon-color; - border-radius: 20px; - width: 22px; - height: 22px; - padding: 2px 0 0 5px; - cursor: pointer; - float: right; - margin: -26px 9px 0 0; - font-size: 12px; - background-color: $white-light; + li { + position: relative; - &:hover, - &:focus { - background-color: $stage-hover-bg; - border: 1px solid transparent; + // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered + &:hover > .mini-pipeline-graph-dropdown-item, + &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item { + @extend .mini-pipeline-graph-dropdown-item:hover; } - svg { - width: 22px; - height: 22px; - left: -6px; - position: relative; - top: -3px; - fill: $action-icon-color; - } + // Action icon on the right + a.ci-action-icon-wrapper { + border-radius: 50%; + border: 1px solid $border-color; + width: $ci-action-icon-size; + height: $ci-action-icon-size; + padding: 2px 0 0 5px; + font-size: 12px; + background-color: $white-light; + position: absolute; + top: 50%; + right: $gl-padding; + margin-top: -#{$ci-action-icon-size / 2}; - &:hover svg, - &:focus svg { - fill: $gl-text-color; - } - } + &:hover, + &:focus { + background-color: $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; + } - // link to the build - .mini-pipeline-graph-dropdown-item { - padding: 3px 7px 4px; - clear: both; - font-weight: $gl-font-weight-normal; - line-height: 1.428571429; - white-space: nowrap; - margin: 0 5px; - border-radius: 3px; + svg { + fill: $gl-text-color-secondary; + width: $ci-action-icon-size; + height: $ci-action-icon-size; + left: -6px; + position: relative; + top: -3px; + } - // build name - .ci-build-text, - .ci-status-text { - font-weight: 200; - overflow: hidden; + &:hover svg, + &:focus svg { + fill: $gl-text-color; + } + } + + // link to the build + .mini-pipeline-graph-dropdown-item { + padding: 3px 7px 4px; + align-items: center; + clear: both; + display: flex; + font-weight: normal; + line-height: $line-height-base; white-space: nowrap; - text-overflow: ellipsis; - max-width: 70%; - color: $gl-text-color-secondary; - margin-left: 2px; - display: inline-block; - top: 1px; - vertical-align: text-bottom; - position: relative; + border-radius: 3px; - @media (max-width: $screen-xs-max) { - max-width: 60%; + .ci-job-name-component { + align-items: center; + display: flex; + flex: 1; } - } - // status icon on the left - .ci-status-icon { - top: 3px; - position: relative; + // build name + .ci-build-text, + .ci-status-text { + font-weight: 200; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 70%; + margin-left: 2px; + display: inline-block; - > svg { - overflow: visible; - width: 18px; - height: 18px; + @media (max-width: $screen-xs-max) { + max-width: 60%; + } } - } - &:hover, - &:focus { - outline: none; - text-decoration: none; - color: $gl-text-color; - background-color: $stage-hover-bg; + .ci-status-icon { + @extend .append-right-8; + + position: relative; + + > svg { + width: $pipeline-dropdown-status-icon-size; + height: $pipeline-dropdown-status-icon-size; + margin: 3px 0; + position: relative; + overflow: visible; + display: block; + } + } + + &:hover, + &:focus { + outline: none; + text-decoration: none; + background-color: $stage-hover-bg; + } } } } @@ -776,16 +785,9 @@ button.mini-pipeline-graph-dropdown-toggle { .big-pipeline-graph-dropdown-menu { width: 195px; min-width: 195px; - left: auto; - right: -195px; - top: -4px; + left: 100%; + top: -10px; box-shadow: 0 1px 5px $black-transparent; - - .mini-pipeline-graph-dropdown-item { - .ci-status-icon { - top: -1px; - } - } } /** @@ -806,15 +808,14 @@ button.mini-pipeline-graph-dropdown-toggle { } &::before { - left: -5px; - margin-top: -6px; + left: -6px; + margin-top: 3px; border-width: 7px 5px 7px 0; border-right-color: $border-color; } &::after { - left: -4px; - margin-top: -9px; + left: -5px; border-width: 10px 7px 10px 0; border-right-color: $white-light; } @@ -927,3 +928,7 @@ button.mini-pipeline-graph-dropdown-toggle { } } } + +.pipelines-container .top-area .nav-controls > .btn:last-child { + float: none; +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 39c4264e496..dd600a27545 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -299,28 +299,6 @@ } } -.project-visibility-level-holder { - .radio { - margin-bottom: 10px; - - i { - margin: 2px 0; - font-size: 20px; - } - - .option-title { - font-weight: $gl-font-weight-normal; - display: inline-block; - color: $gl-text-color; - } - - .option-descr { - margin-left: 29px; - color: $project-option-descr-color; - } - } -} - .save-project-loader { margin-top: 50px; margin-bottom: 50px; @@ -822,8 +800,10 @@ pre.light-well { } } -.new_protected_branch, +.new-protected-branch, .new-protected-tag { + @include new-style-dropdown; + label { margin-top: 6px; font-weight: $gl-font-weight-normal; @@ -843,19 +823,9 @@ pre.light-well { .protected-branches-list, .protected-tags-list { - margin-bottom: 30px; - - a { - color: $gl-text-color; - - &:hover { - color: $gl-link-color; - } + @include new-style-dropdown; - &.is-active { - font-weight: $gl-font-weight-bold; - } - } + margin-bottom: 30px; .settings-message { margin: 0; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 1088eca5322..efc47861768 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -99,6 +99,30 @@ .blob-viewer-container { flex: 1; overflow: auto; + + > div, + .file-content { + display: flex; + } + + > div, + .file-content, + .blob-viewer, + .line-number, + .blob-content, + .code { + min-height: 100%; + width: 100%; + } + + .line-numbers { + min-width: 44px; + } + + .blob-content { + flex: 1; + overflow-x: auto; + } } #tabs { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 8d73246223d..615020ca856 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -190,6 +190,8 @@ input[type="checkbox"]:hover { } .search-holder { + @include new-style-dropdown; + @media (min-width: $screen-sm-min) { display: -webkit-flex; display: flex; diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 15df51e9c69..41a6ba2023a 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -143,6 +143,47 @@ } } +.visibility-level-setting { + .radio { + margin-bottom: 10px; + + i.fa { + margin: 2px 0; + font-size: 20px; + } + + .option-title { + font-weight: $gl-font-weight-normal; + display: inline-block; + color: $gl-text-color; + } + + .option-description, + .option-disabled-reason { + margin-left: 29px; + color: $project-option-descr-color; + } + + .option-disabled-reason { + display: none; + } + + &.disabled { + i.fa { + opacity: 0.5; + } + + .option-description { + display: none; + } + + .option-disabled-reason { + display: block; + } + } + } +} + .prometheus-metrics-monitoring { .panel { .panel-toggle { diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 5b9fafe31bd..6c8d87185e9 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -265,3 +265,7 @@ font-weight: $gl-font-weight-bold; } } + +.todos-filters { + @include new-style-dropdown; +} diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index fa1bc72560e..a99563b7100 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -117,11 +117,14 @@ class Admin::UsersController < Admin::ApplicationController user_params_with_pass = user_params.dup if params[:user][:password].present? - user_params_with_pass.merge!( + password_params = { password: params[:user][:password], - password_confirmation: params[:user][:password_confirmation], - password_expires_at: Time.now - ) + password_confirmation: params[:user][:password_confirmation] + } + + password_params[:password_expires_at] = Time.now unless changing_own_password? + + user_params_with_pass.merge!(password_params) end respond_to do |format| @@ -167,6 +170,10 @@ class Admin::UsersController < Admin::ApplicationController protected + def changing_own_password? + user == current_user + end + def user @user ||= User.find_by!(username: params[:id]) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1d92ea11bda..97922e39ba8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base end def check_password_expiration - if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && current_user.allow_password_authentication? + if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user? return redirect_to new_profile_password_path end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 3120916c5bb..dfc8bd0ba81 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -1,5 +1,7 @@ class AutocompleteController < ApplicationController - skip_before_action :authenticate_user!, only: [:users] + AWARD_EMOJI_MAX = 100 + + skip_before_action :authenticate_user!, only: [:users, :award_emojis] before_action :load_project, only: [:users] before_action :find_users, only: [:users] @@ -39,15 +41,23 @@ class AutocompleteController < ApplicationController project = Project.find_by_id(params[:project_id]) projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id]) - no_project = { - id: 0, - name_with_namespace: 'No project' - } - projects.unshift(no_project) unless params[:offset_id].present? - render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace) end + def award_emojis + emoji_with_count = AwardEmoji + .limit(AWARD_EMOJI_MAX) + .where(user: current_user) + .group(:name) + .order('count_all DESC, name ASC') + .count + + # Transform from hash to array to guarantee json order + # e.g. { 'thumbsup' => 2, 'thumbsdown' = 1 } + # => [{ name: 'thumbsup' }, { name: 'thumbsdown' }] + render json: emoji_with_count.map { |k, v| { name: k } } + end + private def find_users diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index b43b2c5621f..23909bd2d39 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -15,7 +15,17 @@ module IssuableCollections end def merge_requests_collection - merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :head_pipeline, target_project: :namespace, merge_request_diff: :merge_request_diff_commits) + merge_requests_finder.execute.preload( + :source_project, + :target_project, + :author, + :assignee, + :labels, + :milestone, + head_pipeline: :project, + target_project: :namespace, + merge_request_diff: :merge_request_diff_commits + ) end def issues_finder @@ -26,6 +36,34 @@ module IssuableCollections @merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder) end + def redirect_out_of_range(relation, total_pages) + return false if total_pages.zero? + + out_of_range = relation.current_page > total_pages + + if out_of_range + redirect_to(url_for(params.merge(page: total_pages, only_path: true))) + end + + out_of_range + end + + def issues_page_count(relation) + page_count_for_relation(relation, issues_finder.row_count) + end + + def merge_requests_page_count(relation) + page_count_for_relation(relation, merge_requests_finder.row_count) + end + + def page_count_for_relation(relation, row_count) + limit = relation.limit_value.to_f + + return 1 if limit.zero? + + (row_count.to_f / limit).ceil + end + def issuable_finder_for(finder_class) finder_class.new(current_user, filter_params) end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index af5f683bab5..18fd8eb114d 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -3,6 +3,7 @@ module NotesActions extend ActiveSupport::Concern included do + before_action :set_polling_interval_header, only: [:index] before_action :authorize_admin_note!, only: [:update, :destroy] before_action :note_project, only: [:create] end @@ -12,14 +13,18 @@ module NotesActions notes_json = { notes: [], last_fetched_at: current_fetched_at } - @notes = notes_finder.execute.inc_relations_for_view - @notes = prepare_notes_for_rendering(@notes) + notes = notes_finder.execute + .inc_relations_for_view + .reject { |n| n.cross_reference_not_visible_for?(current_user) } - @notes.each do |note| - next if note.cross_reference_not_visible_for?(current_user) + notes = prepare_notes_for_rendering(notes) - notes_json[:notes] << note_json(note) - end + notes_json[:notes] = + if noteable.discussions_rendered_on_frontend? + note_serializer.represent(notes) + else + notes.map { |note| note_json(note) } + end render json: notes_json end @@ -82,22 +87,27 @@ module NotesActions } if note.persisted? - attrs.merge!( - valid: true, - id: note.id, - discussion_id: note.discussion_id(noteable), - html: note_html(note), - note: note.note - ) + attrs[:valid] = true - discussion = note.to_discussion(noteable) - unless discussion.individual_note? + if noteable.nil? || noteable.discussions_rendered_on_frontend? + attrs.merge!(note_serializer.represent(note)) + else attrs.merge!( - discussion_resolvable: discussion.resolvable?, - - diff_discussion_html: diff_discussion_html(discussion), - discussion_html: discussion_html(discussion) + id: note.id, + discussion_id: note.discussion_id(noteable), + html: note_html(note), + note: note.note ) + + discussion = note.to_discussion(noteable) + unless discussion.individual_note? + attrs.merge!( + discussion_resolvable: discussion.resolvable?, + + diff_discussion_html: diff_discussion_html(discussion), + discussion_html: discussion_html(discussion) + ) + end end else attrs.merge!( @@ -168,6 +178,10 @@ module NotesActions ) end + def set_polling_interval_header + Gitlab::PollingInterval.set_header(response, interval: 6_000) + end + def noteable @noteable ||= notes_finder.target end @@ -180,6 +194,10 @@ module NotesActions @notes_finder ||= NotesFinder.new(project, current_user, finder_params) end + def note_serializer + NoteSerializer.new(project: project, noteable: noteable, current_user: current_user) + end + def note_project return @note_project if defined?(@note_project) return nil unless project diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb index ad2f4bbc486..0218ac83441 100644 --- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb +++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb @@ -1,5 +1,8 @@ module RequiresWhitelistedMonitoringClient extend ActiveSupport::Concern + + include Gitlab::CurrentSettings + included do before_action :validate_ip_whitelisted_or_valid_token! end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 5c10d7bc261..7a7bcb1a3d2 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -35,13 +35,13 @@ class Groups::MilestonesController < Groups::ApplicationController end def edit - render_404 if @milestone.is_legacy_group_milestone? + render_404 if @milestone.legacy_group_milestone? end def update # Keep this compatible with legacy group milestones where we have to update # all projects milestones states at once. - if @milestone.is_legacy_group_milestone? + if @milestone.legacy_group_milestone? update_params = milestone_params.select { |key| key == "state_event" } milestones = @milestone.milestones else @@ -67,7 +67,7 @@ class Groups::MilestonesController < Groups::ApplicationController end def milestone_path - if @milestone.is_legacy_group_milestone? + if @milestone.legacy_group_milestone? group_milestone_path(group, @milestone.safe_title, title: @milestone.title) else group_milestone_path(group, @milestone.iid) diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index aa8cf630032..fda944adecd 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,8 +1,6 @@ class PasswordsController < Devise::PasswordsController - include Gitlab::CurrentSettings - before_action :resource_from_email, only: [:create] - before_action :check_password_authentication_available, only: [:create] + before_action :prevent_ldap_reset, only: [:create] before_action :throttle_reset, only: [:create] def edit @@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController self.resource = resource_class.find_by_email(email) end - def check_password_authentication_available - return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?) + def prevent_ldap_reset + return unless resource&.ldap_user? redirect_to after_sending_reset_password_instructions_path_for(resource_name), - alert: "Password authentication is unavailable." + alert: "Cannot reset password for LDAP user." end def throttle_reset diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index c423761ab24..7beb52dd8e8 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController end def authorize_change_password! - render_404 unless @user.allow_password_authentication? + render_404 if @user.ldap_user? end def user_params diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 221e01b415a..d7dd8ddcb7d 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -94,6 +94,6 @@ class Projects::ApplicationController < ApplicationController end def require_pages_enabled! - not_found unless Gitlab.config.pages.enabled + not_found unless @project.pages_available? end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 8893a514207..dc9e6f71152 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -15,7 +15,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_create_issue!, only: [:new, :create] # Allow modify issue - before_action :authorize_update_issue!, only: [:edit, :update] + before_action :authorize_update_issue!, only: [:edit, :update, :move] # Allow create a new branch and empty WIP merge request from current issue before_action :authorize_create_merge_request!, only: [:create_merge_request] @@ -27,10 +27,9 @@ class Projects::IssuesController < Projects::ApplicationController @issues = issues_collection @issues = @issues.page(params[:page]) @issuable_meta_data = issuable_meta_data(@issues, @collection_type) + @total_pages = issues_page_count(@issues) - if @issues.out_of_range? && @issues.total_pages != 0 - return redirect_to url_for(params.merge(page: @issues.total_pages, only_path: true)) - end + return if redirect_out_of_range(@issues, @total_pages) if params[:label_name].present? @labels = LabelsFinder.new(current_user, project_id: @project.id, title: params[:label_name]).execute @@ -91,11 +90,25 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: IssueSerializer.new.represent(@issue) + render json: serializer.represent(@issue) end end end + def discussions + notes = @issue.notes + .inc_relations_for_view + .includes(:noteable) + .fresh + .reject { |n| n.cross_reference_not_visible_for?(current_user) } + + prepare_notes_for_rendering(notes) + + discussions = Discussion.build_collection(notes, @issue) + + render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions) + end + def create create_params = issue_params.merge(spammable_params).merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], @@ -128,25 +141,33 @@ class Projects::IssuesController < Projects::ApplicationController @issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue) + respond_to do |format| + format.html do + recaptcha_check_with_fallback { render :edit } + end + + format.json do + render_issue_json + end + end + + rescue ActiveRecord::StaleObjectError + render_conflict_response + end + + def move + params.require(:move_to_project_id) + if params[:move_to_project_id].to_i > 0 new_project = Project.find(params[:move_to_project_id]) return render_404 unless issue.can_move?(current_user, new_project) - move_service = Issues::MoveService.new(project, current_user) - @issue = move_service.execute(@issue, new_project) + @issue = Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue) end respond_to do |format| - format.html do - recaptcha_check_with_fallback { render :edit } - end - format.json do - if @issue.valid? - render json: IssueSerializer.new.represent(@issue) - else - render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity - end + render_issue_json end end @@ -202,7 +223,7 @@ class Projects::IssuesController < Projects::ApplicationController task_status: @issue.task_status } - if @issue.is_edited? + if @issue.edited? response[:updated_at] = @issue.updated_at response[:updated_by_name] = @issue.last_edited_by.name response[:updated_by_path] = user_path(@issue.last_edited_by) @@ -257,6 +278,14 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless @project.feature_available?(:issues, current_user) end + def render_issue_json + if @issue.valid? + render json: serializer.represent(@issue) + else + render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity + end + end + def issue_params params.require(:issue).permit(*issue_params_attributes) end @@ -287,4 +316,8 @@ class Projects::IssuesController < Projects::ApplicationController redirect_to new_user_session_path, notice: notice end + + def serializer + IssueSerializer.new(current_user: current_user, project: issue.project) + end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 2a3b73577a5..5095d7fd445 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -18,10 +18,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_requests = @merge_requests.page(params[:page]) @merge_requests = @merge_requests.preload(merge_request_diff: :merge_request) @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type) + @total_pages = merge_requests_page_count(@merge_requests) - if @merge_requests.out_of_range? && @merge_requests.total_pages != 0 - return redirect_to url_for(params.merge(page: @merge_requests.total_pages, only_path: true)) - end + return if redirect_out_of_range(@merge_requests, @total_pages) if params[:label_name].present? labels_params = { project_id: @project.id, title: params[:label_name] } @@ -318,14 +317,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo elsif @merge_request.head_pipeline.success? # This can be triggered when a user clicks the auto merge button while # the tests finish at about the same time - MergeWorker.perform_async(@merge_request.id, current_user.id, params) + @merge_request.merge_async(current_user.id, params) :success else :failed end else - MergeWorker.perform_async(@merge_request.id, current_user.id, params) + @merge_request.merge_async(current_user.id, params) :success end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 1d24563a6a6..ed17b3b4689 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -20,7 +20,10 @@ class ProjectsController < Projects::ApplicationController end def new - @project = Project.new + namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id] + return access_denied! if namespace && !can?(current_user, :create_projects, namespace) + + @project = Project.new(namespace_id: namespace&.id) end def edit diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 08a843ada97..9848497f258 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -18,12 +18,12 @@ # sort: string # non_archived: boolean # iids: integer[] +# my_reaction_emoji: string # class IssuableFinder include CreatedAtFilter NONE = '0'.freeze - IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze attr_accessor :current_user, :params @@ -46,6 +46,7 @@ class IssuableFinder items = by_iids(items) items = by_milestone(items) items = by_label(items) + items = by_my_reaction_emoji(items) # Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far items = by_project(items) @@ -60,13 +61,17 @@ class IssuableFinder execute.find_by(*params) end + def row_count + Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state]) + end + # We often get counts for each state by running a query per state, and # counting those results. This is typically slower than running one query # (even if that query is slower than any of the individual state queries) and # grouping and counting within that query. # def count_by_state - count_params = params.merge(state: nil, sort: nil, for_counting: true) + count_params = params.merge(state: nil, sort: nil) labels_count = label_names.any? ? label_names.count : 1 finder = self.class.new(current_user, count_params) counts = Hash.new(0) @@ -89,16 +94,6 @@ class IssuableFinder execute.find_by!(*params) end - def state_counter_cache_key - cache_key(state_counter_cache_key_components) - end - - def clear_caches! - state_counter_cache_key_components_permutations.each do |components| - Rails.cache.delete(cache_key(components)) - end - end - def group return @group if defined?(@group) @@ -371,6 +366,14 @@ class IssuableFinder items end + def by_my_reaction_emoji(items) + if params[:my_reaction_emoji].present? && current_user + items = items.awarded(current_user, params[:my_reaction_emoji]) + end + + items + end + def by_due_date(items) if due_date? if filter_by_no_due_date? @@ -422,20 +425,4 @@ class IssuableFinder def current_user_related? params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me' end - - def state_counter_cache_key_components - opts = params.with_indifferent_access - opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY) - opts.delete_if { |_, value| value.blank? } - - ['issuables_count', klass.to_ability_name, opts.sort] - end - - def state_counter_cache_key_components_permutations - [state_counter_cache_key_components] - end - - def cache_key(components) - Digest::SHA1.hexdigest(components.flatten.join('-')) - end end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 0ec42a4e6eb..d2275139c42 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -14,6 +14,7 @@ # search: string # label_name: string # sort: string +# my_reaction_emoji: string # class IssuesFinder < IssuableFinder CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER @@ -54,44 +55,10 @@ class IssuesFinder < IssuableFinder project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL end - # Anonymous users can't see any confidential issues. - # - # Users without access to see _all_ confidential issues (as in - # `user_can_see_all_confidential_issues?`) are more complicated, because they - # can see confidential issues where: - # 1. They are an assignee. - # 2. They are an author. - # - # That's fine for most cases, but if we're just counting, we need to cache - # effectively. If we cached this accurately, we'd have a cache key for every - # authenticated user without sufficient access to the project. Instead, when - # we are counting, we treat them as if they can't see any confidential issues. - # - # This does mean the counts may be wrong for those users, but avoids an - # explosion in cache keys. - def user_cannot_see_confidential_issues?(for_counting: false) + def user_cannot_see_confidential_issues? return false if user_can_see_all_confidential_issues? - current_user.blank? || for_counting || params[:for_counting] - end - - def state_counter_cache_key_components - extra_components = [ - user_can_see_all_confidential_issues?, - user_cannot_see_confidential_issues?(for_counting: true) - ] - - super + extra_components - end - - def state_counter_cache_key_components_permutations - # Ignore the last two, as we'll provide both options for them. - components = super.first[0..-3] - - [ - components + [false, true], - components + [true, false] - ] + current_user.blank? end def by_assignee(items) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 771da3d441d..d0687d28c21 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -16,6 +16,7 @@ # label_name: string # sort: string # non_archived: boolean +# my_reaction_emoji: string # class MergeRequestsFinder < IssuableFinder def klass diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bcee81bdc15..017df8f6794 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -178,7 +178,7 @@ module ApplicationHelper end def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) - return unless object.is_edited? + return unless object.edited? content_tag :small, class: 'edited-text' do output = content_tag(:span, 'Edited ') @@ -202,7 +202,7 @@ module ApplicationHelper end def support_url - current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/' + Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/' end def page_filter_path(options = {}) @@ -303,7 +303,7 @@ module ApplicationHelper end def show_new_nav? - cookies["new_nav"] == "true" + true end def collapsed_sidebar? diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 3b76da238e0..b93f5f0af1c 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -1,5 +1,8 @@ module ApplicationSettingsHelper extend self + + include Gitlab::CurrentSettings + delegate :gravatar_enabled?, :signup_enabled?, :password_authentication_enabled?, @@ -81,6 +84,18 @@ module ApplicationSettingsHelper end end + def key_restriction_options_for_select(type) + bit_size_options = Gitlab::SSHPublicKey.supported_sizes(type).map do |bits| + ["Must be at least #{bits} bits", bits] + end + + [ + ['Are allowed', 0], + *bit_size_options, + ['Are forbidden', ApplicationSetting::FORBIDDEN_KEY_VALUE] + ] + end + def repository_storages_options_for_select options = Gitlab.config.repositories.storages.map do |name, storage| ["#{name} - #{storage['path']}", name] @@ -113,6 +128,9 @@ module ApplicationSettingsHelper :domain_blacklist_enabled, :domain_blacklist_raw, :domain_whitelist_raw, + :dsa_key_restriction, + :ecdsa_key_restriction, + :ed25519_key_restriction, :email_author_in_body, :enabled_git_access_protocol, :gravatar_enabled, @@ -156,6 +174,7 @@ module ApplicationSettingsHelper :repository_storages, :require_two_factor_authentication, :restricted_visibility_levels, + :rsa_key_restriction, :send_user_confirmation_email, :sentry_dsn, :sentry_enabled, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 9c71d6c7f4c..66dc0b1e6f7 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -1,4 +1,6 @@ module AuthHelper + include Gitlab::CurrentSettings + PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze FORM_BASED_PROVIDERS = [/\Aldap/, 'crowd'].freeze diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index ff305fa39b4..5089da519df 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -97,9 +97,11 @@ module DropdownsHelper end end - def dropdown_footer(&block) + def dropdown_footer(add_content_class: false, &block) content_tag(:div, class: "dropdown-footer") do - if block + if add_content_class + content_tag(:div, capture(&block), class: "dropdown-footer-content") + else capture(&block) end end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 9247b1f72de..b5dece38de1 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -1,9 +1,9 @@ module FormHelper - def form_errors(model) + def form_errors(model, type: 'form') return unless model.errors.any? pluralized = 'error'.pluralize(model.errors.count) - headline = "The form contains the following #{pluralized}:" + headline = "The #{type} contains the following #{pluralized}:" content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:h4, headline) << diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 4123a96911f..dd159d12aa0 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -68,7 +68,7 @@ module GroupsHelper def group_title_link(group, hidable: false) link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do output = - if show_new_nav? + if show_new_nav? && !Rails.env.test? image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16) else "" diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 256de454ecc..49a69df7e5c 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -35,7 +35,7 @@ module IssuablesHelper def serialize_issuable(issuable) case issuable when Issue - IssueSerializer.new.represent(issuable).to_json + IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json when MergeRequest MergeRequestSerializer .new(current_user: current_user, project: issuable.project) @@ -207,12 +207,10 @@ module IssuablesHelper endpoint: project_issue_path(@project, issuable), canUpdate: can?(current_user, :update_issue, issuable), canDestroy: can?(current_user, :destroy_issue, issuable), - canMove: current_user ? issuable.can_move?(current_user) : false, issuableRef: issuable.to_reference, isConfidential: issuable.confidential, - markdownPreviewUrl: preview_markdown_path(@project), - markdownDocs: help_page_path('user/markdown'), - projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id), + markdownPreviewPath: preview_markdown_path(@project), + markdownDocsPath: help_page_path('user/markdown'), issuableTemplates: issuable_templates(issuable), projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path, @@ -229,7 +227,7 @@ module IssuablesHelper end def updated_at_by(issuable) - return {} unless issuable.is_edited? + return {} unless issuable.edited? { updatedAt: issuable.updated_at.to_time.iso8601, @@ -240,16 +238,10 @@ module IssuablesHelper } end - def issuables_count_for_state(issuable_type, state, finder: nil) - finder ||= public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend - cache_key = finder.state_counter_cache_key + def issuables_count_for_state(issuable_type, state) + finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend - @counts ||= {} - @counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do - finder.count_by_state - end - - @counts[cache_key][state] + Gitlab::IssuablesCountForState.new(finder)[state] end def close_issuable_url(issuable) @@ -305,14 +297,6 @@ module IssuablesHelper cookies[:collapsed_gutter] == 'true' end - def issuable_state_scope(issuable) - if issuable.respond_to?(:merged?) && issuable.merged? - :merged - else - issuable.open? ? :opened : :closed - end - end - def issuable_templates(issuable) @issuable_templates ||= case issuable @@ -369,6 +353,8 @@ module IssuablesHelper def issuable_sidebar_options(issuable, can_edit_issuable) { endpoint: "#{issuable_json_path(issuable)}?basic=true", + moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), + projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), editable: can_edit_issuable, currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url), rootPath: root_path, diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 7e1ccb23e9e..3d0fdce6a43 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -47,13 +47,6 @@ module IssuesHelper end end - def bulk_update_milestone_options - milestones = @project.milestones.active.reorder(due_date: :asc, title: :asc).to_a - milestones.unshift(Milestone::None) - - options_from_collection_for_select(milestones, 'id', 'title', params[:milestone_id]) - end - def milestone_options(object) milestones = object.project.milestones.active.reorder(due_date: :asc, title: :asc).to_a milestones.unshift(object.milestone) if object.milestone.present? && object.milestone.closed? @@ -93,14 +86,6 @@ module IssuesHelper return 'hidden' if issue.closed? == closed end - def merge_requests_sentence(merge_requests) - # Sorting based on the `!123` or `group/project!123` reference will sort - # local merge requests first. - merge_requests.map do |merge_request| - merge_request.to_reference(@project) - end.sort.to_sentence(last_word_connector: ', or ') - end - def confidential_icon(issue) icon('eye-slash') if issue.confidential? end @@ -137,7 +122,7 @@ module IssuesHelper end def awards_sort(awards) - awards.sort_by do |award, notes| + awards.sort_by do |award, award_emojis| if award == "thumbsup" 0 elsif award == "thumbsdown" @@ -148,18 +133,6 @@ module IssuesHelper end.to_h end - def due_date_options - options = [ - Issue::AnyDueDate, - Issue::NoDueDate, - Issue::DueThisWeek, - Issue::DueThisMonth, - Issue::Overdue - ] - - options_from_collection_for_select(options, 'name', 'title', params[:due_date]) - end - def link_to_discussions_to_resolve(merge_request, single_discussion = nil) link_text = merge_request.to_reference link_text += " (discussion #{single_discussion.first_note.id})" if single_discussion diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 86666022a2a..446a59030a6 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -164,7 +164,7 @@ module MilestonesHelper def group_milestone_route(milestone, params = {}) params = nil if params.empty? - if milestone.is_legacy_group_milestone? + if milestone.legacy_group_milestone? group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params) else group_milestone_path(@group, milestone.iid, milestone: params) diff --git a/app/helpers/milestones_routing_helper.rb b/app/helpers/milestones_routing_helper.rb index 766d5262018..a0b2616f224 100644 --- a/app/helpers/milestones_routing_helper.rb +++ b/app/helpers/milestones_routing_helper.rb @@ -1,16 +1,16 @@ module MilestonesRoutingHelper def milestone_path(milestone, *args) - if milestone.is_group_milestone? + if milestone.group_milestone? group_milestone_path(milestone.group, milestone, *args) - elsif milestone.is_project_milestone? + elsif milestone.project_milestone? project_milestone_path(milestone.project, milestone, *args) end end def milestone_url(milestone, *args) - if milestone.is_group_milestone? + if milestone.group_milestone? group_milestone_url(milestone.group, milestone, *args) - elsif milestone.is_project_milestone? + elsif milestone.project_milestone? project_milestone_url(milestone.project, milestone, *args) end end diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 7f656b8caae..d7df9bb06d2 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -4,7 +4,8 @@ module NamespacesHelper end def namespaces_options(selected = :current_user, display_path: false, extra_group: nil) - groups = current_user.owned_groups + current_user.masters_groups + groups = current_user.owned_groups + current_user.masters_groups + users = [current_user.namespace] unless extra_group.nil? || extra_group.is_a?(Group) extra_group = Group.find(extra_group) if Namespace.find(extra_group).kind == 'group' @@ -14,22 +15,9 @@ module NamespacesHelper groups |= [extra_group] end - users = [current_user.namespace] - - data_attr_group = { 'data-options-parent' => 'groups' } - data_attr_users = { 'data-options-parent' => 'users' } - - group_opts = [ - "Groups", groups.sort_by(&:human_name).map { |g| [display_path ? g.full_path : g.human_name, g.id, data_attr_group] } - ] - - users_opts = [ - "Users", users.sort_by(&:human_name).map { |u| [display_path ? u.path : u.human_name, u.id, data_attr_users] } - ] - options = [] - options << group_opts - options << users_opts + options << options_for_group(groups, display_path: display_path, type: 'group') + options << options_for_group(users, display_path: display_path, type: 'user') if selected == :current_user && current_user.namespace selected = current_user.namespace.id @@ -45,4 +33,23 @@ module NamespacesHelper avatar_icon(namespace.owner.email, size) end end + + private + + def options_for_group(namespaces, display_path:, type:) + group_label = type.pluralize + elements = namespaces.sort_by(&:human_name).map! do |n| + [display_path ? n.full_path : n.human_name, n.id, + data: { + options_parent: group_label, + visibility_level: n.visibility_level_value, + visibility: n.visibility, + name: n.name, + show_path: (type == 'group') ? group_path(n) : user_path(n), + edit_path: (type == 'group') ? edit_group_path(n) : nil + }] + end + + [group_label.camelize, elements] + end end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index b63b3b70903..73b3386fe9c 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -38,7 +38,7 @@ module NavHelper end def layout_nav_class - return [] if show_new_nav? + return 'page-with-new-nav' if show_new_nav? class_names = [] class_names << 'page-with-layout-nav' if defined?(nav) && nav @@ -50,4 +50,12 @@ module NavHelper def nav_control_class "nav-control" if current_user end + + def user_dropdown_class + class_names = [] + class_names << 'header-user-dropdown-toggle' + class_names << 'impersonated-user' if session[:impersonator_id] + + class_names + end end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index e857e837c16..8c5e258f519 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -93,11 +93,13 @@ module NotesHelper end end - def notes_url + def notes_url(params = {}) if @snippet.is_a?(PersonalSnippet) - snippet_notes_path(@snippet) + snippet_notes_path(@snippet, params) else - project_noteable_notes_path(@project, target_id: @noteable.id, target_type: @noteable.class.name.underscore) + params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore) + + project_noteable_notes_path(@project, params) end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index bee4950e414..02fe82ea872 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,4 +1,6 @@ module ProjectsHelper + include Gitlab::CurrentSettings + def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') @@ -60,7 +62,7 @@ module ProjectsHelper project_link = link_to project_path(project), { class: "project-item-select-holder" } do output = - if show_new_nav? + if show_new_nav? && !Rails.env.test? project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16) else "" @@ -70,12 +72,6 @@ module ProjectsHelper output.html_safe end - if current_user - project_link << button_tag(type: 'button', class: 'dropdown-toggle-caret js-projects-dropdown-toggle', aria: { label: 'Toggle switch project dropdown' }, data: { target: '.js-dropdown-menu-projects', toggle: 'dropdown', order_by: 'last_activity_at' }) do - icon("chevron-down") - end - end - "#{namespace_link} / #{project_link}".html_safe end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 08fd97cd048..c98f65c7644 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -22,8 +22,14 @@ module SystemNoteHelper 'duplicate' => 'icon_clone' }.freeze + def system_note_icon_name(note) + ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] + end + def icon_for_system_note(note) - icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] + icon_name = system_note_icon_name(note) custom_icon(icon_name) if icon_name end + + extend self end diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index ee701076a14..3308ab0c259 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -119,4 +119,8 @@ module TabHelper 'active' if current_controller?('oauth/applications') end + + def sidebar_link(href, title: nil, css: nil, &block) + link_to capture(&block), href, title: (title if collapsed_sidebar?), class: css, aria: { label: title } + end end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 35755bc149b..46867d2d974 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -63,6 +63,68 @@ module VisibilityLevelHelper end end + def restricted_visibility_level_description(level) + level_name = Gitlab::VisibilityLevel.level_name(level) + "#{level_name.capitalize} visibility has been restricted by the administrator." + end + + def disallowed_visibility_level_description(level, form_model) + case form_model + when Project + disallowed_project_visibility_level_description(level, form_model) + when Group + disallowed_group_visibility_level_description(level, form_model) + end + end + + # Note: these messages closely mirror the form validation strings found in the project + # model and any changes or additons to these may also need to be made there. + def disallowed_project_visibility_level_description(level, project) + level_name = Gitlab::VisibilityLevel.level_name(level).downcase + reasons = [] + instructions = '' + + unless project.visibility_level_allowed_as_fork?(level) + reasons << "the fork source project has lower visibility" + end + + unless project.visibility_level_allowed_by_group?(level) + errors = visibility_level_errors_for_group(project.group, level_name) + + reasons << errors[:reason] + instructions << errors[:instruction] + end + + reasons = reasons.any? ? ' because ' + reasons.to_sentence : '' + "This project cannot be #{level_name}#{reasons}.#{instructions}".html_safe + end + + # Note: these messages closely mirror the form validation strings found in the group + # model and any changes or additons to these may also need to be made there. + def disallowed_group_visibility_level_description(level, group) + level_name = Gitlab::VisibilityLevel.level_name(level).downcase + reasons = [] + instructions = '' + + unless group.visibility_level_allowed_by_projects?(level) + reasons << "it contains projects with higher visibility" + end + + unless group.visibility_level_allowed_by_sub_groups?(level) + reasons << "it contains sub-groups with higher visibility" + end + + unless group.visibility_level_allowed_by_parent?(level) + errors = visibility_level_errors_for_group(group.parent, level_name) + + reasons << errors[:reason] + instructions << errors[:instruction] + end + + reasons = reasons.any? ? ' because ' + reasons.to_sentence : '' + "This group cannot be #{level_name}#{reasons}.#{instructions}".html_safe + end + def visibility_icon_description(form_model) case form_model when Project @@ -95,7 +157,18 @@ module VisibilityLevelHelper :default_group_visibility, to: :current_application_settings - def skip_level?(form_model, level) - form_model.is_a?(Project) && !form_model.visibility_level_allowed?(level) + def disallowed_visibility_level?(form_model, level) + return false unless form_model.respond_to?(:visibility_level_allowed?) + !form_model.visibility_level_allowed?(level) + end + + private + + def visibility_level_errors_for_group(group, level_name) + group_name = link_to group.name, group_path(group) + change_visiblity = link_to 'change the visibility', edit_group_path(group) + + { reason: "the visibility of #{group_name} is #{group.visibility}", + instruction: " To make this group #{level_name}, you must first #{change_visiblity} of the parent group." } end end diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb index 654468bc7fe..8e99db444d6 100644 --- a/app/mailers/base_mailer.rb +++ b/app/mailers/base_mailer.rb @@ -1,11 +1,13 @@ class BaseMailer < ActionMailer::Base + include Gitlab::CurrentSettings + around_action :render_with_default_locale helper ApplicationHelper helper MarkupHelper attr_accessor :current_user - helper_method :current_user, :can? + helper_method :current_user, :can?, :current_application_settings default from: proc { default_sender_address.format } default reply_to: proc { default_reply_to_address.format } diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 8e446ff6dd8..3568e72e463 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,6 +13,11 @@ class ApplicationSetting < ActiveRecord::Base [\r\n] # any number of newline characters }x + # Setting a key restriction to `-1` means that all keys of this type are + # forbidden. + FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN + SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze + serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize @@ -146,6 +151,12 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0 } + SUPPORTED_KEY_TYPES.each do |type| + validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } + end + + validates :allowed_key_types, presence: true + validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| unless Gitlab::VisibilityLevel.options.value?(level) @@ -171,6 +182,7 @@ class ApplicationSetting < ActiveRecord::Base end before_validation :ensure_uuid! + before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -221,6 +233,9 @@ class ApplicationSetting < ActiveRecord::Base default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], disabled_oauth_sign_in_sources: [], domain_whitelist: Settings.gitlab['domain_whitelist'], + dsa_key_restriction: 0, + ecdsa_key_restriction: 0, + ed25519_key_restriction: 0, gravatar_enabled: Settings.gravatar['enabled'], help_page_text: nil, help_page_hide_commercial_content: false, @@ -239,6 +254,7 @@ class ApplicationSetting < ActiveRecord::Base max_attachment_size: Settings.gitlab['max_attachment_size'], password_authentication_enabled: Settings.gitlab['password_authentication_enabled'], performance_bar_allowed_group_id: nil, + rsa_key_restriction: 0, plantuml_enabled: false, plantuml_url: nil, project_export_enabled: true, @@ -413,6 +429,18 @@ class ApplicationSetting < ActiveRecord::Base usage_ping_can_be_configured? && super end + def allowed_key_types + SUPPORTED_KEY_TYPES.select do |type| + key_restriction_for(type) != FORBIDDEN_KEY_VALUE + end + end + + def key_restriction_for(type) + attr_name = "#{type}_key_restriction" + + has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend + end + private def ensure_uuid! diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 91b62dabbcd..4d1a15c53aa 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base scope :downvotes, -> { where(name: DOWNVOTE_NAME) } scope :upvotes, -> { where(name: UPVOTE_NAME) } + after_save :expire_etag_cache + after_destroy :expire_etag_cache + class << self def votes_for_collection(ids, type) select('name', 'awardable_id', 'COUNT(*) as count') @@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base def upvote? self.name == UPVOTE_NAME end + + def expire_etag_cache + awardable.try(:expire_etag_cache) + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 095192e9894..ba3156154ac 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -3,6 +3,7 @@ module Ci include TokenAuthenticatable include AfterCommitQueue include Presentable + include Importable belongs_to :runner belongs_to :trigger_request @@ -26,6 +27,7 @@ module Ci validates :coverage, numericality: true, allow_blank: true validates :ref, presence: true + validates :protected, inclusion: { in: [true, false], unless: :importing? }, on: :create scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } @@ -34,6 +36,7 @@ module Ci scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } + scope :ref_protected, -> { where(protected: true) } mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader @@ -387,7 +390,9 @@ module Ci [ { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, - { key: 'GITLAB_USER_EMAIL', value: user.email, public: true } + { key: 'GITLAB_USER_EMAIL', value: user.email, public: true }, + { key: 'GITLAB_USER_LOGIN', value: user.username, public: true }, + { key: 'GITLAB_USER_NAME', value: user.name, public: true } ] end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 2d40f8012a3..35d14b6e297 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -36,6 +36,7 @@ module Ci validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } validates :status, presence: { unless: :importing? } + validates :protected, inclusion: { in: [true, false], unless: :importing? }, on: :create validate :valid_commit_sha, unless: :importing? after_create :keep_around_commits, unless: :importing? @@ -304,6 +305,10 @@ module Ci @stage_seeds ||= config_processor.stage_seeds(self) end + def has_kubernetes_active? + project.kubernetes_service&.active? + end + def has_stage_seeds? stage_seeds.any? end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index c6d23898560..b1798084787 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -5,7 +5,7 @@ module Ci RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour AVAILABLE_SCOPES = %w[specific shared active paused online].freeze - FORM_EDITABLE = %i[description tag_list active run_untagged locked].freeze + FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -35,11 +35,17 @@ module Ci end validate :tag_constraints + validates :access_level, presence: true acts_as_taggable after_destroy :cleanup_runner_queue + enum access_level: { + not_protected: 0, + ref_protected: 1 + } + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. @@ -106,6 +112,8 @@ module Ci end def can_pick?(build) + return false if self.ref_protected? && !build.protected? + assignable_for?(build.project) && accepting_tags?(build) end @@ -142,7 +150,7 @@ module Ci expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: false) end - def is_runner_queue_value_latest?(value) + def runner_queue_value_latest?(value) ensure_runner_queue_value == value if value.present? end diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb index c58ce5c3717..2c860598281 100644 --- a/app/models/ci/trigger_request.rb +++ b/app/models/ci/trigger_request.rb @@ -6,6 +6,10 @@ module Ci belongs_to :pipeline, foreign_key: :commit_id has_many :builds + # We switched to Ci::PipelineVariable from Ci::TriggerRequest.variables. + # Ci::TriggerRequest doesn't save variables anymore. + validates :variables, absence: true + serialize :variables # rubocop:disable Cop/ActiveRecordSerialize def user_variables diff --git a/app/models/commit.rb b/app/models/commit.rb index d41c88b4e30..ba3845df867 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -251,6 +251,28 @@ class Commit project.repository.next_branch("cherry-pick-#{short_id}", mild: true) end + def cherry_pick_description(user) + message_body = "(cherry picked from commit #{sha})" + + if merged_merge_request?(user) + commits_in_merge_request = merged_merge_request(user).commits + + if commits_in_merge_request.present? + message_body << "\n" + + commits_in_merge_request.reverse.each do |commit_in_merge| + message_body << "\n#{commit_in_merge.short_id} #{commit_in_merge.title}" + end + end + end + + message_body + end + + def cherry_pick_message(user) + %Q{#{message}\n\n#{cherry_pick_description(user)}} + end + def revert_description(user) if merged_merge_request?(user) "This reverts merge request #{merged_merge_request(user).to_reference}" @@ -383,6 +405,6 @@ class Commit end def gpg_commit - @gpg_commit ||= Gitlab::Gpg::Commit.for_commit(self) + @gpg_commit ||= Gitlab::Gpg::Commit.new(self) end end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 842c6e5cb50..f3888528940 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -38,6 +38,14 @@ class CommitStatus < ActiveRecord::Base scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } + enum failure_reason: { + unknown_failure: nil, + script_failure: 1, + api_failure: 2, + stuck_or_timeout_failure: 3, + runner_system_failure: 4 + } + state_machine :status do event :process do transition [:skipped, :manual] => :created @@ -79,6 +87,11 @@ class CommitStatus < ActiveRecord::Base commit_status.finished_at = Time.now end + before_transition any => :failed do |commit_status, transition| + failure_reason = transition.args.first + commit_status.failure_reason = failure_reason + end + after_transition do |commit_status, transition| next if transition.loopback? diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index f4f9b037957..9adc309a22b 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -11,6 +11,21 @@ module Awardable end module ClassMethods + def awarded(user, name) + sql = <<~EOL + EXISTS ( + SELECT TRUE + FROM award_emoji + WHERE user_id = :user_id AND + name = :name AND + awardable_type = :awardable_type AND + awardable_id = #{self.arel_table.name}.id + ) + EOL + + where(sql, user_id: user.id, name: name, awardable_type: self.name) + end + def order_upvotes_desc order_votes_desc(AwardEmoji::UPVOTE_NAME) end diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb index 28623d257a6..c0a3099f676 100644 --- a/app/models/concerns/editable.rb +++ b/app/models/concerns/editable.rb @@ -1,7 +1,7 @@ module Editable extend ActiveSupport::Concern - def is_edited? + def edited? last_edited_at.present? && last_edited_at != created_at end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3731b7c8577..681c3241dbb 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -6,6 +6,7 @@ # module Issuable extend ActiveSupport::Concern + include Gitlab::SQL::Pattern include CacheMarkdownField include Participable include Mentionable @@ -122,7 +123,9 @@ module Issuable # # Returns an ActiveRecord::Relation. def search(query) - where(arel_table[:title].matches("%#{query}%")) + title = to_fuzzy_arel(:title, query) + + where(title) end # Searches for records with a matching title or description. @@ -133,10 +136,10 @@ module Issuable # # Returns an ActiveRecord::Relation. def full_search(query) - t = arel_table - pattern = "%#{query}%" + title = to_fuzzy_arel(:title, query) + description = to_fuzzy_arel(:description, query) - where(t[:title].matches(pattern).or(t[:description].matches(pattern))) + where(title&.or(description)) end def sort(method, excluded_labels: []) diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index f0998465822..710fc1ed647 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -70,19 +70,19 @@ module Milestoneish due_date && due_date.past? end - def is_group_milestone? + def group_milestone? false end - def is_project_milestone? + def project_milestone? false end - def is_legacy_group_milestone? + def legacy_group_milestone? false end - def is_dashboard_milestone? + def dashboard_milestone? false end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index c7bdc997eca..1c4ddabcad5 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -24,6 +24,10 @@ module Noteable DiscussionNote::NOTEABLE_TYPES.include?(base_class_name) end + def discussions_rendered_on_frontend? + false + end + def discussion_notes notes end @@ -38,7 +42,7 @@ module Noteable def grouped_diff_discussions(*args) # Doesn't use `discussion_notes`, because this may include commit diff notes - # besides MR diff notes, that we do no want to display on the MR Changes tab. + # besides MR diff notes, that we do not want to display on the MR Changes tab. notes.inc_relations_for_view.grouped_diff_discussions(*args) end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index f2707022a4b..731d9b9a745 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -28,7 +28,7 @@ module Spammable def submittable_as_spam? if user_agent_detail - user_agent_detail.submittable? && current_application_settings.akismet_enabled + user_agent_detail.submittable? && Gitlab::CurrentSettings.current_application_settings.akismet_enabled else false end diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb index fac7c5e5c85..86eb4ec76fc 100644 --- a/app/models/dashboard_milestone.rb +++ b/app/models/dashboard_milestone.rb @@ -3,7 +3,7 @@ class DashboardMilestone < GlobalMilestone { authorized_only: true } end - def is_dashboard_milestone? + def dashboard_milestone? true end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 056c49e7162..7bcded5b5e1 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -49,7 +49,7 @@ class Deployment < ActiveRecord::Base # created before then could have a `sha` referring to a commit that no # longer exists in the repository, so just ignore those. begin - project.repository.is_ancestor?(commit.id, sha) + project.repository.ancestor?(commit.id, sha) rescue Rugged::OdbError false end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index d1cec7613af..b80da7b246a 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -81,6 +81,10 @@ class Discussion last_note.author end + def updated? + last_updated_at != created_at + end + def id first_note.discussion_id(context_noteable) end diff --git a/app/models/environment.rb b/app/models/environment.rb index e9ebf0637f3..435eeaf0e2e 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -114,7 +114,7 @@ class Environment < ActiveRecord::Base end def ref_path - "refs/environments/#{Shellwords.shellescape(name)}" + "refs/#{Repository::REF_ENVIRONMENTS}/#{Shellwords.shellescape(name)}" end def formatted_external_url diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 3df60ddc950..1633acd4fa9 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -56,7 +56,7 @@ class GpgKey < ActiveRecord::Base def verified_user_infos user_infos.select do |user_info| - user_info[:email] == user.email + user.verified_email?(user_info[:email]) end end @@ -64,13 +64,17 @@ class GpgKey < ActiveRecord::Base user_infos.map do |user_info| [ user_info[:email], - user_info[:email] == user.email + user.verified_email?(user_info[:email]) ] end.to_h end def verified? - emails_with_verified_status.any? { |_email, verified| verified } + emails_with_verified_status.values.any? + end + + def verified_and_belongs_to_email?(email) + emails_with_verified_status.fetch(email, false) end def update_invalid_gpg_signatures @@ -78,11 +82,14 @@ class GpgKey < ActiveRecord::Base end def revoke - GpgSignature.where(gpg_key: self, valid_signature: true).update_all( - gpg_key_id: nil, - valid_signature: false, - updated_at: Time.zone.now - ) + GpgSignature + .where(gpg_key: self) + .where.not(verification_status: GpgSignature.verification_statuses[:unknown_key]) + .update_all( + gpg_key_id: nil, + verification_status: GpgSignature.verification_statuses[:unknown_key], + updated_at: Time.zone.now + ) destroy end diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 50fb35c77ec..454c90d5fc4 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -1,9 +1,21 @@ class GpgSignature < ActiveRecord::Base include ShaAttribute + include IgnorableColumn + + ignore_column :valid_signature sha_attribute :commit_sha sha_attribute :gpg_key_primary_keyid + enum verification_status: { + unverified: 0, + verified: 1, + same_user_different_email: 2, + other_user: 3, + unverified_key: 4, + unknown_key: 5 + } + belongs_to :project belongs_to :gpg_key @@ -20,6 +32,6 @@ class GpgSignature < ActiveRecord::Base end def gpg_commit - Gitlab::Gpg::Commit.new(project, commit_sha) + Gitlab::Gpg::Commit.new(commit) end end diff --git a/app/models/group.rb b/app/models/group.rb index cb3ee032f69..190b27cf66b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -26,6 +26,8 @@ class Group < Namespace validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validate :visibility_level_allowed_by_projects + validate :visibility_level_allowed_by_sub_groups + validate :visibility_level_allowed_by_parent validates :avatar, file_size: { maximum: 200.kilobytes.to_i } @@ -102,15 +104,24 @@ class Group < Namespace full_name end - def visibility_level_allowed_by_projects - allowed_by_projects = self.projects.where('visibility_level > ?', self.visibility_level).none? + def visibility_level_allowed_by_parent?(level = self.visibility_level) + return true unless parent_id && parent_id.nonzero? - unless allowed_by_projects - level_name = Gitlab::VisibilityLevel.level_name(visibility_level).downcase - self.errors.add(:visibility_level, "#{level_name} is not allowed since there are projects with higher visibility.") - end + level <= parent.visibility_level + end + + def visibility_level_allowed_by_projects?(level = self.visibility_level) + !projects.where('visibility_level > ?', level).exists? + end - allowed_by_projects + def visibility_level_allowed_by_sub_groups?(level = self.visibility_level) + !children.where('visibility_level > ?', level).exists? + end + + def visibility_level_allowed?(level = self.visibility_level) + visibility_level_allowed_by_parent?(level) && + visibility_level_allowed_by_projects?(level) && + visibility_level_allowed_by_sub_groups?(level) end def avatar_url(**args) @@ -275,11 +286,29 @@ class Group < Namespace list_of_ids.reverse.map { |group| variables[group.id] }.compact.flatten end - protected + private def update_two_factor_requirement return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed? users.find_each(&:update_two_factor_requirement) end + + def visibility_level_allowed_by_parent + return if visibility_level_allowed_by_parent? + + errors.add(:visibility_level, "#{visibility} is not allowed since the parent group has a #{parent.visibility} visibility.") + end + + def visibility_level_allowed_by_projects + return if visibility_level_allowed_by_projects? + + errors.add(:visibility_level, "#{visibility} is not allowed since this group contains projects with higher visibility.") + end + + def visibility_level_allowed_by_sub_groups + return if visibility_level_allowed_by_sub_groups? + + errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.") + end end diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index 65249bd7bfc..98135ee3c8b 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -17,7 +17,7 @@ class GroupMilestone < GlobalMilestone { group_id: group.id } end - def is_legacy_group_milestone? + def legacy_group_milestone? true end end diff --git a/app/models/issue.rb b/app/models/issue.rb index b9aa937d2f9..8c7d492e605 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -269,7 +269,17 @@ class Issue < ActiveRecord::Base end end + def discussions_rendered_on_frontend? + true + end + + def update_project_counter_caches? + state_changed? || confidential_changed? + end + def update_project_counter_caches + return unless update_project_counter_caches? + Projects::OpenIssuesCountService.new(project).refresh_cache end diff --git a/app/models/key.rb b/app/models/key.rb index 49bc26122fa..a6b4dcfec0d 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -1,6 +1,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include Gitlab::CurrentSettings include Sortable LAST_USED_AT_REFRESH_TIME = 1.day.to_i @@ -12,14 +13,18 @@ class Key < ActiveRecord::Base validates :title, presence: true, length: { maximum: 255 } + validates :key, presence: true, length: { maximum: 5000 }, format: { with: /\A(ssh|ecdsa)-.*\Z/ } + validates :fingerprint, uniqueness: true, presence: { message: 'cannot be generated' } + validate :key_meets_restrictions + delegate :name, :email, to: :user, prefix: true after_commit :add_to_shell, on: :create @@ -80,6 +85,10 @@ class Key < ActiveRecord::Base SystemHooksService.new.execute_hooks_for(self, :destroy) end + def public_key + @public_key ||= Gitlab::SSHPublicKey.new(key) + end + private def generate_fingerprint @@ -87,7 +96,27 @@ class Key < ActiveRecord::Base return unless self.key.present? - self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint + self.fingerprint = public_key.fingerprint + end + + def key_meets_restrictions + restriction = current_application_settings.key_restriction_for(public_key.type) + + if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE + errors.add(:key, forbidden_key_type_message) + elsif public_key.bits < restriction + errors.add(:key, "must be at least #{restriction} bits") + end + end + + def forbidden_key_type_message + allowed_types = + current_application_settings + .allowed_key_types + .map(&:upcase) + .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + + "type is forbidden. Must be #{allowed_types}" end def notify_user diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index dbc73ed3cd4..724fb4ccef1 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -241,6 +241,14 @@ class MergeRequest < ActiveRecord::Base end end + # Calls `MergeWorker` to proceed with the merge process and + # updates `merge_jid` with the MergeWorker#jid. + # This helps tracking enqueued and ongoing merge jobs. + def merge_async(user_id, params) + jid = MergeWorker.perform_async(id, user_id, params) + update_column(:merge_jid, jid) + end + def first_commit merge_request_diff ? merge_request_diff.first_commit : compare_commits.first end @@ -384,9 +392,7 @@ class MergeRequest < ActiveRecord::Base end def merge_ongoing? - return false unless merge_jid - - Gitlab::SidekiqStatus.num_running([merge_jid]) > 0 + !!merge_jid && !merged? end def closed_without_fork? @@ -599,6 +605,8 @@ class MergeRequest < ActiveRecord::Base self.merge_requests_closing_issues.delete_all closes_issues(current_user).each do |issue| + next if issue.is_a?(ExternalIssue) + self.merge_requests_closing_issues.create!(issue: issue) end end @@ -797,7 +805,7 @@ class MergeRequest < ActiveRecord::Base end def ref_path - "refs/merge-requests/#{iid}/head" + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end def ref_fetched? @@ -819,7 +827,7 @@ class MergeRequest < ActiveRecord::Base lock_mr yield ensure - unlock_mr if locked? + unlock_mr end end @@ -936,20 +944,19 @@ class MergeRequest < ActiveRecord::Base true end + def update_project_counter_caches? + state_changed? + end + def update_project_counter_caches + return unless update_project_counter_caches? + Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache end private def write_ref - target_project.repository.with_repo_branch_commit( - source_project.repository, source_branch) do |commit| - if commit - target_project.repository.write_ref(ref_path, commit.sha) - else - raise Rugged::ReferenceError, 'source repository is empty' - end - end + target_project.repository.fetch_source_branch(source_project.repository, source_branch, ref_path) end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 01e0d0155a3..a3070a12b7c 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -163,7 +163,7 @@ class Milestone < ActiveRecord::Base # Milestone.first.to_reference(same_namespace_project) # => "gitlab-ce%1" # def to_reference(from_project = nil, format: :iid, full: false) - return if is_group_milestone? && format != :name + return if group_milestone? && format != :name format_reference = milestone_format_reference(format) reference = "#{self.class.reference_prefix}#{format_reference}" @@ -207,11 +207,11 @@ class Milestone < ActiveRecord::Base group || project end - def is_group_milestone? + def group_milestone? group_id.present? end - def is_project_milestone? + def project_milestone? project_id.present? end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index e7bc1d1b080..e7cbc5170e8 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -195,6 +195,10 @@ class Namespace < ActiveRecord::Base parent.present? end + def subgroup? + has_parent? + end + def soft_delete_without_removing_associations # We can't use paranoia's `#destroy` since this will hard-delete projects. # Project uses `pending_delete` instead of the acts_as_paranoia gem. diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 0e5acb22d50..3845e485413 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -152,14 +152,14 @@ module Network end def find_free_parent_space(range, space_base, space_step, space_default) - if is_overlap?(range, space_default) + if overlap?(range, space_default) find_free_space(range, space_step, space_base, space_default) else space_default end end - def is_overlap?(range, overlap_space) + def overlap?(range, overlap_space) range.each do |i| if i != range.first && i != range.last && diff --git a/app/models/note.rb b/app/models/note.rb index a752c897d63..1073c115630 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -299,6 +299,17 @@ class Note < ActiveRecord::Base end end + def expire_etag_cache + return unless noteable&.discussions_rendered_on_frontend? + + key = Gitlab::Routing.url_helpers.project_noteable_notes_path( + project, + target_type: noteable_type.underscore, + target_id: noteable_id + ) + Gitlab::EtagCaching::Store.new.touch(key) + end + private def keep_around_commit @@ -326,15 +337,4 @@ class Note < ActiveRecord::Base def set_discussion_id self.discussion_id ||= discussion_class.discussion_id(self) end - - def expire_etag_cache - return unless for_issue? - - key = Gitlab::Routing.url_helpers.project_noteable_notes_path( - noteable.project, - target_type: noteable_type.underscore, - target_id: noteable.id - ) - Gitlab::EtagCaching::Store.new.touch(key) - end end diff --git a/app/models/project.rb b/app/models/project.rb index 8ade8c3fc38..01d04bc8d04 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -19,6 +19,7 @@ class Project < ActiveRecord::Base include Routable extend Gitlab::ConfigHelper + extend Gitlab::CurrentSettings BoardLimitExceeded = Class.new(StandardError) @@ -67,7 +68,6 @@ class Project < ActiveRecord::Base acts_as_taggable - attr_accessor :new_default_branch attr_accessor :old_path_with_namespace attr_accessor :template_name attr_writer :pipeline_status @@ -222,6 +222,7 @@ class Project < ActiveRecord::Base validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create + validate :can_create_repository?, on: [:create, :update], if: ->(project) { !project.persisted? || project.renamed? } validate :avatar_type, if: ->(project) { project.avatar.present? && project.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } @@ -372,11 +373,7 @@ class Project < ActiveRecord::Base if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists? project.run_after_commit do - begin - Projects::HousekeepingService.new(project).execute - rescue Projects::HousekeepingService::LeaseTaken => e - Rails.logger.info("Could not perform housekeeping for project #{project.full_path} (#{project.id}): #{e}") - end + Projects::AfterImportService.new(project).execute end end end @@ -468,7 +465,7 @@ class Project < ActiveRecord::Base end def repository_storage_path - Gitlab.config.repositories.storages[repository_storage]['path'] + Gitlab.config.repositories.storages[repository_storage].try(:[], 'path') end def team @@ -583,7 +580,7 @@ class Project < ActiveRecord::Base end def valid_import_url? - valid? || errors.messages[:import_url].nil? + valid?(:import_url) || errors.messages[:import_url].nil? end def create_or_update_import_data(data: nil, credentials: nil) @@ -1000,6 +997,20 @@ class Project < ActiveRecord::Base end end + # Check if repository already exists on disk + def can_create_repository? + return false unless repository_storage_path + + expires_full_path_cache # we need to clear cache to validate renames correctly + + if gitlab_shell.exists?(repository_storage_path, "#{disk_path}.git") + errors.add(:base, 'There is already a repository with that name on disk') + return false + end + + true + end + def create_repository(force: false) # Forked import is handled asynchronously return if forked? && !force @@ -1235,6 +1246,10 @@ class Project < ActiveRecord::Base File.join(pages_path, 'public') end + def pages_available? + Gitlab.config.pages.enabled && !namespace.subgroup? + end + def remove_private_deploy_keys exclude_keys_linked_to_other_projects = <<-SQL NOT EXISTS ( @@ -1494,6 +1509,10 @@ class Project < ActiveRecord::Base self.storage_version.nil? end + def renamed? + persisted? && path_changed? + end + private def storage diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 7b15a5dd04d..818cfb01b14 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -101,9 +101,9 @@ class ChatNotificationService < Service when "push", "tag_push" ChatMessage::PushMessage.new(data) when "issue" - ChatMessage::IssueMessage.new(data) unless is_update?(data) + ChatMessage::IssueMessage.new(data) unless update?(data) when "merge_request" - ChatMessage::MergeMessage.new(data) unless is_update?(data) + ChatMessage::MergeMessage.new(data) unless update?(data) when "note" ChatMessage::NoteMessage.new(data) when "pipeline" @@ -136,7 +136,7 @@ class ChatNotificationService < Service project.web_url end - def is_update?(data) + def update?(data) data[:object_attributes][:action] == 'update' end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index f422e0ea036..976d85246a8 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -85,9 +85,9 @@ class HipchatService < Service when "push", "tag_push" create_push_message(data) when "issue" - create_issue_message(data) unless is_update?(data) + create_issue_message(data) unless update?(data) when "merge_request" - create_merge_request_message(data) unless is_update?(data) + create_merge_request_message(data) unless update?(data) when "note" create_note_message(data) when "pipeline" @@ -282,7 +282,7 @@ class HipchatService < Service "<a href=\"#{project_url}\">#{project_name}</a>" end - def is_update?(data) + def update?(data) data[:object_attributes][:action] == 'update' end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 5f0d0802ac9..89bfc5f9a9c 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -2,6 +2,8 @@ class ProtectedBranch < ActiveRecord::Base include Gitlab::ShellAdapter include ProtectedRef + extend Gitlab::CurrentSettings + protected_ref_access_levels :merge, :push # Check if branch name is marked as protected in the system diff --git a/app/models/repository.rb b/app/models/repository.rb index 9fb2e2aa306..035f85a0b46 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1,6 +1,18 @@ require 'securerandom' class Repository + REF_MERGE_REQUEST = 'merge-requests'.freeze + REF_KEEP_AROUND = 'keep-around'.freeze + REF_ENVIRONMENTS = 'environments'.freeze + + RESERVED_REFS_NAMES = %W[ + heads + tags + #{REF_ENVIRONMENTS} + #{REF_KEEP_AROUND} + #{REF_ENVIRONMENTS} + ].freeze + include Gitlab::ShellAdapter include RepositoryMirroring @@ -8,7 +20,6 @@ class Repository delegate :ref_name_for_sha, to: :raw_repository - CommitError = Class.new(StandardError) CreateTreeError = Class.new(StandardError) # Methods that cache data from the Git repository. @@ -60,6 +71,10 @@ class Repository @project = project end + def ==(other) + @disk_path == other.disk_path + end + def raw_repository return nil unless full_path @@ -75,17 +90,8 @@ class Repository ) end - # - # Git repository can contains some hidden refs like: - # /refs/notes/* - # /refs/git-as-svn/* - # /refs/pulls/* - # This refs by default not visible in project page and not cloned to client side. - # - # This method return true if repository contains some content visible in project page. - # - def has_visible_content? - branch_count > 0 + def inspect + "#<#{self.class.name}:#{@disk_path}>" end def commit(ref = 'HEAD') @@ -160,32 +166,25 @@ class Repository end def add_branch(user, branch_name, ref) - newrev = commit(ref).try(:sha) - - return false unless newrev - - GitOperationService.new(user, self).add_branch(branch_name, newrev) + branch = raw_repository.add_branch(branch_name, committer: user, target: ref) after_create_branch - find_branch(branch_name) + + branch + rescue Gitlab::Git::Repository::InvalidRef + false end def add_tag(user, tag_name, target, message = nil) - newrev = commit(target).try(:id) - options = { message: message, tagger: user_to_committer(user) } if message - - return false unless newrev - - GitOperationService.new(user, self).add_tag(tag_name, newrev, options) - - find_tag(tag_name) + raw_repository.add_tag(tag_name, committer: user, target: target, message: message) + rescue Gitlab::Git::Repository::InvalidRef + false end def rm_branch(user, branch_name) before_remove_branch - branch = find_branch(branch_name) - GitOperationService.new(user, self).rm_branch(branch) + raw_repository.rm_branch(branch_name, committer: user) after_remove_branch true @@ -193,9 +192,8 @@ class Repository def rm_tag(user, tag_name) before_remove_tag - tag = find_tag(tag_name) - GitOperationService.new(user, self).rm_tag(tag) + raw_repository.rm_tag(tag_name, committer: user) after_remove_tag true @@ -234,10 +232,10 @@ class Repository begin write_ref(keep_around_ref_name(sha), sha) rescue Rugged::ReferenceError => ex - Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}" + Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}" rescue Rugged::OSError => ex raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/ - Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}" + Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}" end end @@ -764,16 +762,30 @@ class Repository multi_action(**options) end + def with_branch(user, *args) + result = Gitlab::Git::OperationService.new(user, raw_repository).with_branch(*args) do |start_commit| + yield start_commit + end + + newrev, should_run_after_create, should_run_after_create_branch = result + + after_create if should_run_after_create + after_create_branch if should_run_after_create_branch + + newrev + end + # rubocop:disable Metrics/ParameterLists def multi_action( user:, branch_name:, message:, actions:, author_email: nil, author_name: nil, start_branch_name: nil, start_project: project) - GitOperationService.new(user, self).with_branch( + with_branch( + user, branch_name, start_branch_name: start_branch_name, - start_project: start_project) do |start_commit| + start_repository: start_project.repository.raw_repository) do |start_commit| index = Gitlab::Git::Index.new(raw_repository) @@ -826,7 +838,8 @@ class Repository end def merge(user, source, merge_request, options = {}) - GitOperationService.new(user, self).with_branch( + with_branch( + user, merge_request.target_branch) do |start_commit| our_commit = start_commit.sha their_commit = source @@ -846,17 +859,18 @@ class Repository merge_request.update(in_progress_merge_commit_sha: commit_id) commit_id end - rescue Repository::CommitError # when merge_index.conflicts? + rescue Gitlab::Git::CommitError # when merge_index.conflicts? false end def revert( user, commit, branch_name, start_branch_name: nil, start_project: project) - GitOperationService.new(user, self).with_branch( + with_branch( + user, branch_name, start_branch_name: start_branch_name, - start_project: start_project) do |start_commit| + start_repository: start_project.repository.raw_repository) do |start_commit| revert_tree_id = check_revert_content(commit, start_commit.sha) unless revert_tree_id @@ -876,10 +890,11 @@ class Repository def cherry_pick( user, commit, branch_name, start_branch_name: nil, start_project: project) - GitOperationService.new(user, self).with_branch( + with_branch( + user, branch_name, start_branch_name: start_branch_name, - start_project: start_project) do |start_commit| + start_repository: start_project.repository.raw_repository) do |start_commit| cherry_pick_tree_id = check_cherry_pick_content(commit, start_commit.sha) unless cherry_pick_tree_id @@ -888,7 +903,7 @@ class Repository committer = user_to_committer(user) - create_commit(message: commit.message, + create_commit(message: commit.cherry_pick_message(user), author: { email: commit.author_email, name: commit.author_name, @@ -901,7 +916,7 @@ class Repository end def resolve_conflicts(user, branch_name, params) - GitOperationService.new(user, self).with_branch(branch_name) do + with_branch(user, branch_name) do committer = user_to_committer(user) create_commit(params.merge(author: committer, committer: committer)) @@ -944,7 +959,7 @@ class Repository if branch_commit same_head = branch_commit.id == root_ref_commit.id - !same_head && is_ancestor?(branch_commit.id, root_ref_commit.id) + !same_head && ancestor?(branch_commit.id, root_ref_commit.id) else nil end @@ -958,12 +973,12 @@ class Repository nil end - def is_ancestor?(ancestor_id, descendant_id) + def ancestor?(ancestor_id, descendant_id) return false if ancestor_id.nil? || descendant_id.nil? Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled| if is_enabled - raw_repository.is_ancestor?(ancestor_id, descendant_id) + raw_repository.ancestor?(ancestor_id, descendant_id) else rugged_is_ancestor?(ancestor_id, descendant_id) end @@ -991,28 +1006,6 @@ class Repository run_git(args).first.lines.map(&:strip) end - def with_repo_branch_commit(start_repository, start_branch_name) - return yield(nil) if start_repository.empty_repo? - - branch_name_or_sha = - if start_repository == self - start_branch_name - else - tmp_ref = fetch_ref( - start_repository.path_to_repo, - "#{Gitlab::Git::BRANCH_REF_PREFIX}#{start_branch_name}", - "refs/tmp/#{SecureRandom.hex}/head" - ) - - start_repository.commit(start_branch_name).sha - end - - yield(commit(branch_name_or_sha)) - - ensure - rugged.references.delete(tmp_ref) if tmp_ref - end - def add_remote(name, url) raw_repository.remote_add(name, url) rescue Rugged::ConfigError @@ -1027,17 +1020,15 @@ class Repository end def fetch_remote(remote, forced: false, no_tags: false) - gitlab_shell.fetch_remote(repository_storage_path, disk_path, remote, forced: forced, no_tags: no_tags) + gitlab_shell.fetch_remote(raw_repository, remote, forced: forced, no_tags: no_tags) end - def fetch_ref(source_path, source_ref, target_ref) - args = %W(fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref}) - message, status = run_git(args) - - # Make sure ref was created, and raise Rugged::ReferenceError when not - raise Rugged::ReferenceError, message if status != 0 + def fetch_source_branch(source_repository, source_branch, local_ref) + raw_repository.fetch_source_branch(source_repository.raw_repository, source_branch, local_ref) + end - target_ref + def compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:) + raw_repository.compare_source_branch(target_branch_name, source_repository.raw_repository, source_branch_name, straight: straight) end def create_ref(ref, ref_path) @@ -1118,12 +1109,6 @@ class Repository private - def run_git(args) - circuit_breaker.perform do - Gitlab::Popen.popen([Gitlab.config.git.bin_path, *args], path_to_repo) - end - end - def blob_data_at(sha, path) blob = blob_at(sha, path) return unless blob @@ -1159,7 +1144,7 @@ class Repository end def keep_around_ref_name(sha) - "refs/keep-around/#{sha}" + "refs/#{REF_KEEP_AROUND}/#{sha}" end def repository_event(event, tags = {}) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 09d5ff46618..9533aa7f555 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -10,6 +10,8 @@ class Snippet < ActiveRecord::Base include Spammable include Editable + extend Gitlab::CurrentSettings + cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description cache_markdown_field :content diff --git a/app/models/user.rb b/app/models/user.rb index fbd08bc4d0a..c5b5f09722f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,9 +2,11 @@ require 'carrierwave/orm/activerecord' class User < ActiveRecord::Base extend Gitlab::ConfigHelper + extend Gitlab::CurrentSettings include Gitlab::ConfigHelper include Gitlab::CurrentSettings + include Gitlab::SQL::Pattern include Avatarable include Referable include Sortable @@ -303,7 +305,7 @@ class User < ActiveRecord::Base # Returns an ActiveRecord::Relation. def search(query) table = arel_table - pattern = "%#{query}%" + pattern = User.to_pattern(query) order = <<~SQL CASE @@ -601,7 +603,7 @@ class User < ActiveRecord::Base end def require_personal_access_token_creation_for_git_auth? - return false if allow_password_authentication? || ldap_user? + return false if current_application_settings.password_authentication_enabled? || ldap_user? PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none? end @@ -642,11 +644,6 @@ class User < ActiveRecord::Base @personal_projects_count ||= personal_projects.count end - def projects_limit_percent - return 100 if projects_limit.zero? - (personal_projects.count.to_f / projects_limit) * 100 - end - def recent_push(project_ids = nil) # Get push events not earlier than 2 hours ago events = recent_events.code_push.where("created_at > ?", Time.now - 2.hours) @@ -664,10 +661,6 @@ class User < ActiveRecord::Base end end - def projects_sorted_by_activity - authorized_projects.sorted_by_activity - end - def several_namespaces? owned_groups.any? || masters_groups.any? end @@ -1048,6 +1041,10 @@ class User < ActiveRecord::Base ensure_rss_token! end + def verified_email?(email) + self.email == email + end + protected # override, from Devise::Validatable diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 5c7c2204374..f2315bb3dbb 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -84,7 +84,7 @@ class WikiPage # The formatted title of this page. def title if @attributes[:title] - self.class.unhyphenize(@attributes[:title]) + CGI.unescape_html(self.class.unhyphenize(@attributes[:title])) else "" end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index a605a3457c8..8fa7b2753c7 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -1,8 +1,6 @@ require_dependency 'declarative_policy' class BasePolicy < DeclarativePolicy::Base - include Gitlab::CurrentSettings - desc "User is an instance admin" with_options scope: :user, score: 0 condition(:admin) { @user&.admin? } @@ -15,6 +13,6 @@ class BasePolicy < DeclarativePolicy::Base desc "The application is restricted from public visibility" condition(:restricted_public_level, scope: :global) do - current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) + Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 0133091db57..a925fac7d3e 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -17,13 +17,13 @@ class ProjectPolicy < BasePolicy desc "Project has public builds enabled" condition(:public_builds, scope: :subject) { project.public_builds? } - # For guest access we use #is_team_member? so we can use + # For guest access we use #team_member? so we can use # project.members, which gets cached in subject scope. # This is safe because team_access_level is guaranteed # by ProjectAuthorization's validation to be at minimum # GUEST desc "User has guest access" - condition(:guest) { is_team_member? } + condition(:guest) { team_member? } desc "User has reporter access" condition(:reporter) { team_access_level >= Gitlab::Access::REPORTER } @@ -293,7 +293,7 @@ class ProjectPolicy < BasePolicy private - def is_team_member? + def team_member? return false if @user.nil? greedy_load_subject = false diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index c495c3f39bb..255475e1fe6 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -17,5 +17,16 @@ module Ci "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" end end + + def trigger_variables + return [] unless trigger_request + + @trigger_variables ||= + if pipeline.variables.any? + pipeline.variables.map(&:to_runner_variable) + else + trigger_request.user_variables + end + end end end diff --git a/app/serializers/award_emoji_entity.rb b/app/serializers/award_emoji_entity.rb new file mode 100644 index 00000000000..6e03cd02392 --- /dev/null +++ b/app/serializers/award_emoji_entity.rb @@ -0,0 +1,4 @@ +class AwardEmojiEntity < Grape::Entity + expose :name + expose :user, using: API::Entities::UserSafe +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb new file mode 100644 index 00000000000..0a92e3f8167 --- /dev/null +++ b/app/serializers/discussion_entity.rb @@ -0,0 +1,10 @@ +class DiscussionEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :reply_id + expose :expanded?, as: :expanded + + expose :notes, using: NoteEntity + + expose :individual_note?, as: :individual_note +end diff --git a/app/serializers/discussion_serializer.rb b/app/serializers/discussion_serializer.rb new file mode 100644 index 00000000000..ed5e1224bb2 --- /dev/null +++ b/app/serializers/discussion_serializer.rb @@ -0,0 +1,3 @@ +class DiscussionSerializer < BaseSerializer + entity DiscussionEntity +end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index bd5211b8e58..61c7a428745 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity expose :total_time_spent expose :human_time_estimate expose :human_total_time_spent + expose :milestone, using: API::Entities::Milestone + expose :labels, using: LabelEntity end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index c189a4992da..0d6feb78173 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity expose :due_date expose :moved_to_id expose :project_id - expose :milestone, using: API::Entities::Milestone - expose :labels, using: LabelEntity expose :web_url do |issue| project_issue_path(issue.project, issue) end + + expose :current_user do + expose :can_create_note do |issue| + can?(request.current_user, :create_note, issue.project) + end + + expose :can_update do |issue| + can?(request.current_user, :update_issue, issue) + end + end + + expose :create_note_path do |issue| + project_notes_path(issue.project, target_type: 'issue', target_id: issue.id) + end + + expose :preview_note_path do |issue| + preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.id) + end end diff --git a/app/serializers/note_attachment_entity.rb b/app/serializers/note_attachment_entity.rb new file mode 100644 index 00000000000..1ad50568ab9 --- /dev/null +++ b/app/serializers/note_attachment_entity.rb @@ -0,0 +1,5 @@ +class NoteAttachmentEntity < Grape::Entity + expose :url + expose :filename + expose :image?, as: :image +end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb new file mode 100644 index 00000000000..7d50e0ff10d --- /dev/null +++ b/app/serializers/note_entity.rb @@ -0,0 +1,60 @@ +class NoteEntity < API::Entities::Note + include RequestAwareEntity + + expose :type + + expose :author, using: NoteUserEntity + + expose :human_access do |note| + note.project.team.human_max_access(note.author_id) + end + + unexpose :note, as: :body + expose :note + + expose :redacted_note_html, as: :note_html + + expose :last_edited_at, if: -> (note, _) { note.edited? } + expose :last_edited_by, using: NoteUserEntity, if: -> (note, _) { note.edited? } + + expose :current_user do + expose :can_edit do |note| + Ability.can_edit_note?(request.current_user, note) + end + end + + expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| + SystemNoteHelper.system_note_icon_name(note) + end + + expose :discussion_id do |note| + note.discussion_id(request.noteable) + end + + expose :emoji_awardable?, as: :emoji_awardable + expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity + expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note| + if note.for_personal_snippet? + toggle_award_emoji_snippet_note_path(note.noteable, note) + else + toggle_award_emoji_project_note_path(note.project, note.id) + end + end + + expose :report_abuse_path do |note| + new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) + end + + expose :path do |note| + if note.for_personal_snippet? + snippet_note_path(note.noteable, note) + else + project_note_path(note.project, note) + end + end + + expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } + expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| + delete_attachment_project_note_path(note.project, note) + end +end diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb new file mode 100644 index 00000000000..2afe40d7a34 --- /dev/null +++ b/app/serializers/note_serializer.rb @@ -0,0 +1,3 @@ +class NoteSerializer < BaseSerializer + entity NoteEntity +end diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb new file mode 100644 index 00000000000..7289f3a0222 --- /dev/null +++ b/app/serializers/note_user_entity.rb @@ -0,0 +1,3 @@ +class NoteUserEntity < UserEntity + unexpose :web_url +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb new file mode 100644 index 00000000000..49a71ebac61 --- /dev/null +++ b/app/serializers/user_serializer.rb @@ -0,0 +1,3 @@ +class UserSerializer < BaseSerializer + entity UserEntity +end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb index 59153cbbc0a..aa6f0e841c9 100644 --- a/app/services/akismet_service.rb +++ b/app/services/akismet_service.rb @@ -1,4 +1,6 @@ class AkismetService + include Gitlab::CurrentSettings + attr_accessor :owner, :text, :options def initialize(owner, text, options = {}) @@ -7,7 +9,7 @@ class AkismetService @options = options end - def is_spam? + def spam? return false unless akismet_enabled? params = { diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index 7dae5880931..9a636346899 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -1,6 +1,6 @@ module Auth class ContainerRegistryAuthenticationService < BaseService - include Gitlab::CurrentSettings + extend Gitlab::CurrentSettings AUDIENCE = 'container_registry'.freeze diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index d0ba9f89460..414c01b2546 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -12,10 +12,11 @@ module Ci tag: tag?, trigger_requests: Array(trigger_request), user: current_user, - pipeline_schedule: schedule + pipeline_schedule: schedule, + protected: project.protected_for?(ref) ) - result = validate(current_user || trigger_request.trigger.owner, + result = validate(current_user, ignore_skip_ci: ignore_skip_ci, save_on_errors: save_on_errors) diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb deleted file mode 100644 index b2aa457bbd5..00000000000 --- a/app/services/ci/create_trigger_request_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# This class is deprecated because we're closing Ci::TriggerRequest. -# New class is PipelineTriggerService (app/services/ci/pipeline_trigger_service.rb) -# which is integrated with Ci::PipelineVariable instaed of Ci::TriggerRequest. -# We remove this class after we removed v1 and v3 API. This class is still being -# referred by such legacy code. -module Ci - module CreateTriggerRequestService - Result = Struct.new(:trigger_request, :pipeline) - - def self.execute(project, trigger, ref, variables = nil) - trigger_request = trigger.trigger_requests.create(variables: variables) - - pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref) - .execute(:trigger, ignore_skip_ci: true, trigger_request: trigger_request) - - Result.new(trigger_request, pipeline) - end - end -end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 414f672cc6a..b8db709211a 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -77,7 +77,9 @@ module Ci end def new_builds - Ci::Build.pending.unstarted + builds = Ci::Build.pending.unstarted + builds = builds.ref_protected if runner.ref_protected? + builds end def shared_runner_build_limits_feature_enabled? diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index ea3b8d66ed9..d67b9f5cc56 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -3,7 +3,7 @@ module Ci CLONE_ACCESSORS = %i[pipeline project ref tag options commands name allow_failure stage_id stage stage_idx trigger_request yaml_variables when environment coverage_regex - description tag_list].freeze + description tag_list protected].freeze def execute(build) reprocess!(build).tap do |new_build| diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index dbd0b9ef43a..f96f2931508 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -17,7 +17,7 @@ module Commits new_commit = create_commit! success(result: new_commit) - rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Repository::CommitError, Gitlab::Git::HooksService::PreReceiveError => ex + rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::HooksService::PreReceiveError => ex error(ex.message) end diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index a5ae4927412..53f16a236d2 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -11,26 +11,8 @@ class CompareService end def execute(target_project, target_branch, straight: false) - # If compare with other project we need to fetch ref first - target_project.repository.with_repo_branch_commit( - start_project.repository, - start_branch_name) do |commit| - break unless commit + raw_compare = target_project.repository.compare_source_branch(target_branch, start_project.repository, start_branch_name, straight: straight) - compare(commit.sha, target_project, target_branch, straight: straight) - end - end - - private - - def compare(source_sha, target_project, target_branch, straight:) - raw_compare = Gitlab::Git::Compare.new( - target_project.repository.raw_repository, - target_branch, - source_sha, - straight: straight - ) - - Compare.new(raw_compare, target_project, straight: straight) + Compare.new(raw_compare, target_project, straight: straight) if raw_compare end end diff --git a/app/services/git_operation_service.rb b/app/services/git_operation_service.rb deleted file mode 100644 index 6b7a56e6922..00000000000 --- a/app/services/git_operation_service.rb +++ /dev/null @@ -1,159 +0,0 @@ -class GitOperationService - attr_reader :committer, :repository - - def initialize(committer, new_repository) - committer = Gitlab::Git::Committer.from_user(committer) if committer.is_a?(User) - @committer = committer - - @repository = new_repository - end - - def add_branch(branch_name, newrev) - ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name - oldrev = Gitlab::Git::BLANK_SHA - - update_ref_in_hooks(ref, newrev, oldrev) - end - - def rm_branch(branch) - ref = Gitlab::Git::BRANCH_REF_PREFIX + branch.name - oldrev = branch.target - newrev = Gitlab::Git::BLANK_SHA - - update_ref_in_hooks(ref, newrev, oldrev) - end - - def add_tag(tag_name, newrev, options = {}) - ref = Gitlab::Git::TAG_REF_PREFIX + tag_name - oldrev = Gitlab::Git::BLANK_SHA - - with_hooks(ref, newrev, oldrev) do |service| - # We want to pass the OID of the tag object to the hooks. For an - # annotated tag we don't know that OID until after the tag object - # (raw_tag) is created in the repository. That is why we have to - # update the value after creating the tag object. Only the - # "post-receive" hook will receive the correct value in this case. - raw_tag = repository.rugged.tags.create(tag_name, newrev, options) - service.newrev = raw_tag.target_id - end - end - - def rm_tag(tag) - ref = Gitlab::Git::TAG_REF_PREFIX + tag.name - oldrev = tag.target - newrev = Gitlab::Git::BLANK_SHA - - update_ref_in_hooks(ref, newrev, oldrev) do - repository.rugged.tags.delete(tag_name) - end - end - - # Whenever `start_branch_name` is passed, if `branch_name` doesn't exist, - # it would be created from `start_branch_name`. - # If `start_project` is passed, and the branch doesn't exist, - # it would try to find the commits from it instead of current repository. - def with_branch( - branch_name, - start_branch_name: nil, - start_project: repository.project, - &block) - - start_repository = start_project.repository - start_branch_name = nil if start_repository.empty_repo? - - if start_branch_name && !start_repository.branch_exists?(start_branch_name) - raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.full_path}" - end - - update_branch_with_hooks(branch_name) do - repository.with_repo_branch_commit( - start_repository, - start_branch_name || branch_name, - &block) - end - end - - private - - def update_branch_with_hooks(branch_name) - update_autocrlf_option - - was_empty = repository.empty? - - # Make commit - newrev = yield - - unless newrev - raise Repository::CommitError.new('Failed to create commit') - end - - branch = repository.find_branch(branch_name) - oldrev = find_oldrev_from_branch(newrev, branch) - - ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name - update_ref_in_hooks(ref, newrev, oldrev) - - # If repo was empty expire cache - repository.after_create if was_empty - repository.after_create_branch if - was_empty || Gitlab::Git.blank_ref?(oldrev) - - newrev - end - - def find_oldrev_from_branch(newrev, branch) - return Gitlab::Git::BLANK_SHA unless branch - - oldrev = branch.target - - if oldrev == repository.rugged.merge_base(newrev, branch.target) - oldrev - else - raise Repository::CommitError.new('Branch diverged') - end - end - - def update_ref_in_hooks(ref, newrev, oldrev) - with_hooks(ref, newrev, oldrev) do - update_ref(ref, newrev, oldrev) - end - end - - def with_hooks(ref, newrev, oldrev) - Gitlab::Git::HooksService.new.execute( - committer, - repository, - oldrev, - newrev, - ref) do |service| - - yield(service) - end - end - - # Gitaly note: JV: wait with migrating #update_ref until we know how to migrate its call sites. - def update_ref(ref, newrev, oldrev) - # We use 'git update-ref' because libgit2/rugged currently does not - # offer 'compare and swap' ref updates. Without compare-and-swap we can - # (and have!) accidentally reset the ref to an earlier state, clobbering - # commits. See also https://github.com/libgit2/libgit2/issues/1534. - command = %W[#{Gitlab.config.git.bin_path} update-ref --stdin -z] - _, status = Gitlab::Popen.popen( - command, - repository.path_to_repo) do |stdin| - stdin.write("update #{ref}\x00#{newrev}\x00#{oldrev}\x00") - end - - unless status.zero? - raise Repository::CommitError.new( - "Could not update branch #{Gitlab::Git.branch_name(ref)}." \ - " Please refresh and try again.") - end - end - - def update_autocrlf_option - if repository.raw_repository.autocrlf != :input - repository.raw_repository.autocrlf = :input - end - end -end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index e81a56672e2..bb61136e33b 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -30,7 +30,7 @@ class GitPushService < BaseService @project.repository.after_create_branch # Re-find the pushed commits. - if is_default_branch? + if default_branch? # Initial push to the default branch. Take the full history of that branch as "newly pushed". process_default_branch else @@ -50,7 +50,7 @@ class GitPushService < BaseService # Update the bare repositories info/attributes file using the contents of the default branches # .gitattributes file - update_gitattributes if is_default_branch? + update_gitattributes if default_branch? end execute_related_hooks @@ -66,7 +66,7 @@ class GitPushService < BaseService end def update_caches - if is_default_branch? + if default_branch? if push_to_new_branch? # If this is the initial push into the default branch, the file type caches # will already be reset as a result of `Project#change_head`. @@ -108,7 +108,7 @@ class GitPushService < BaseService # Schedules processing of commit messages. def process_commit_messages - default = is_default_branch? + default = default_branch? @push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit| if commit.matches_cross_reference_regex? @@ -202,7 +202,7 @@ class GitPushService < BaseService Gitlab::Git.branch_ref?(params[:ref]) end - def is_default_branch? + def default_branch? Gitlab::Git.branch_ref?(params[:ref]) && (Gitlab::Git.ref_name(params[:ref]) == project.default_branch || project.default_branch.nil?) end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 1486db046b5..8b967b78052 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -56,6 +56,7 @@ class IssuableBaseService < BaseService params.delete(:assignee_id) params.delete(:due_date) params.delete(:canonical_issue_id) + params.delete(:project) end filter_assignee(issuable) @@ -244,9 +245,7 @@ class IssuableBaseService < BaseService new_assignees = issuable.assignees.to_a affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees) - # Don't clear the project cache, because it will be handled by the - # appropriate service (close / reopen / merge / etc.). - invalidate_cache_counts(issuable, users: affected_assignees.compact, skip_project_cache: true) + invalidate_cache_counts(issuable, users: affected_assignees.compact) after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -340,18 +339,9 @@ class IssuableBaseService < BaseService create_labels_note(issuable, old_labels) if issuable.labels != old_labels end - def invalidate_cache_counts(issuable, users: [], skip_project_cache: false) + def invalidate_cache_counts(issuable, users: []) users.each do |user| user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend end - - unless skip_project_cache - case issuable - when Issue - IssuesFinder.new(nil, project_id: issuable.project_id).clear_caches! - when MergeRequest - MergeRequestsFinder.new(nil, project_id: issuable.target_project_id).clear_caches! - end - end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index a1f31abd164..b4ca3966505 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -6,7 +6,7 @@ module Issues handle_move_between_ids(issue) filter_spam_check_params change_issue_duplicate(issue) - update(issue) + move_issue_to_new_project(issue) || update(issue) end def before_update(issue) @@ -74,6 +74,17 @@ module Issues end end + def move_issue_to_new_project(issue) + target_project = params.delete(:target_project) + + return unless target_project && + issue.can_move?(current_user, target_project) && + target_project != issue.project + + update(issue) + Issues::MoveService.new(project, current_user).execute(issue, target_project) + end + private def get_issue_if_allowed(project, id) diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 5be749cd6a0..b2b6c5627fb 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -26,10 +26,12 @@ module MergeRequests merge_request.in_locked_state do if commit after_merge + clean_merge_jid success end end rescue MergeError => e + clean_merge_jid log_merge_error(e.message, save_message_on_model: true) end @@ -70,6 +72,10 @@ module MergeRequests end end + def clean_merge_jid + merge_request.update_column(:merge_jid, nil) + end + def branch_deletion_user @merge_request.force_remove_source_branch? ? @merge_request.author : current_user end diff --git a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb index aed5287940e..850deb0ac7a 100644 --- a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb +++ b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb @@ -30,7 +30,7 @@ module MergeRequests next end - MergeWorker.perform_async(merge_request.id, merge_request.merge_user_id, merge_request.merge_params) + merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params) end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 75a65aecd1a..2832d893e95 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -83,7 +83,7 @@ module MergeRequests if merge_request.head_pipeline && merge_request.head_pipeline.active? MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request) else - MergeWorker.perform_async(merge_request.id, current_user.id, {}) + merge_request.merge_async(current_user.id, {}) end end diff --git a/app/services/milestones/close_service.rb b/app/services/milestones/close_service.rb index 776ec4b287b..5b06c4b601d 100644 --- a/app/services/milestones/close_service.rb +++ b/app/services/milestones/close_service.rb @@ -1,7 +1,7 @@ module Milestones class CloseService < Milestones::BaseService def execute(milestone) - if milestone.close && milestone.is_project_milestone? + if milestone.close && milestone.project_milestone? event_service.close_milestone(milestone, current_user) end diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb index aef3124c7e3..ed2e833d833 100644 --- a/app/services/milestones/create_service.rb +++ b/app/services/milestones/create_service.rb @@ -3,7 +3,7 @@ module Milestones def execute milestone = parent.milestones.new(params) - if milestone.save && milestone.is_project_milestone? + if milestone.save && milestone.project_milestone? event_service.open_milestone(milestone, current_user) end diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb index 600ebcfbecb..b18651476a8 100644 --- a/app/services/milestones/destroy_service.rb +++ b/app/services/milestones/destroy_service.rb @@ -1,7 +1,7 @@ module Milestones class DestroyService < Milestones::BaseService def execute(milestone) - return unless milestone.is_project_milestone? + return unless milestone.project_milestone? Milestone.transaction do update_params = { milestone: nil } diff --git a/app/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb index 5b8b682caaf..3efb33157c5 100644 --- a/app/services/milestones/reopen_service.rb +++ b/app/services/milestones/reopen_service.rb @@ -1,7 +1,7 @@ module Milestones class ReopenService < Milestones::BaseService def execute(milestone) - if milestone.activate && milestone.is_project_milestone? + if milestone.activate && milestone.project_milestone? event_service.reopen_milestone(milestone, current_user) end diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb new file mode 100644 index 00000000000..3047268b2d1 --- /dev/null +++ b/app/services/projects/after_import_service.rb @@ -0,0 +1,24 @@ +module Projects + class AfterImportService + RESERVED_REF_PREFIXES = Repository::RESERVED_REFS_NAMES.map { |n| File.join('refs', n, '/') } + + def initialize(project) + @project = project + end + + def execute + Projects::HousekeepingService.new(@project).execute do + repository.delete_all_refs_except(RESERVED_REF_PREFIXES) + end + rescue Projects::HousekeepingService::LeaseTaken => e + Rails.logger.info( + "Could not perform housekeeping for project #{@project.full_path} (#{@project.id}): #{e}") + end + + private + + def repository + @repository ||= @project.repository + end + end +end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index a0cd52014a2..71533da31b1 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -44,6 +44,8 @@ module Projects @project.namespace_id = current_user.namespace_id end + yield(@project) if block_given? + @project.creator = current_user if forked_from_project_id diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index d66ef676088..dcef8b66215 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -26,6 +26,8 @@ module Projects lease_uuid = try_obtain_lease raise LeaseTaken unless lease_uuid.present? + yield if block_given? + execute_gitlab_shell_gc(lease_uuid) end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 394b336a638..d34903c9989 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -1,5 +1,7 @@ module Projects class UpdatePagesService < BaseService + include Gitlab::CurrentSettings + BLOCK_SIZE = 32.kilobytes MAX_SIZE = 1.terabyte SITE_PATH = 'public/'.freeze @@ -51,7 +53,7 @@ module Projects log_error("Projects::UpdatePagesService: #{message}") @status.allow_failure = !latest? @status.description = message - @status.drop + @status.drop(:script_failure) super end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index c7832c47e1a..9cdb9935bea 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -505,6 +505,24 @@ module QuickActions end end + desc 'Move this issue to another project.' + explanation do |path_to_project| + "Moves this issue to #{path_to_project}." + end + params 'path/to/project' + condition do + issuable.is_a?(Issue) && + issuable.persisted? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :move do |target_project_path| + target_project = Project.find_by_full_path(target_project_path) + + if target_project.present? + @updates[:target_project] = target_project + end + end + def extract_users(params) return [] if params.nil? diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb index 3e65b7d31a3..73ea3018fbd 100644 --- a/app/services/spam_service.rb +++ b/app/services/spam_service.rb @@ -45,7 +45,7 @@ class SpamService def check(api) return false unless request && check_for_spam? - return false unless akismet.is_spam? + return false unless akismet.spam? create_spam_log(api) true diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 1763f64a4e4..1f66a2668f9 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -142,7 +142,7 @@ module SystemNoteService # # Returns the created Note object def change_milestone(noteable, project, author, milestone) - format = milestone&.is_group_milestone? ? :name : :iid + format = milestone&.group_milestone? ? :name : :iid body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}" create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone')) diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb index 6c5b2baff41..76700dfcdee 100644 --- a/app/services/upload_service.rb +++ b/app/services/upload_service.rb @@ -1,4 +1,6 @@ class UploadService + include Gitlab::CurrentSettings + def initialize(model, file, uploader_class = FileUploader) @model, @file, @uploader_class = model, file, uploader_class end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index ff234a3440f..6f05500adea 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -1,5 +1,7 @@ module Users class BuildService < BaseService + include Gitlab::CurrentSettings + def initialize(current_user, params = {}) @current_user = current_user @params = params.dup diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb new file mode 100644 index 00000000000..204be827941 --- /dev/null +++ b/app/validators/key_restriction_validator.rb @@ -0,0 +1,29 @@ +class KeyRestrictionValidator < ActiveModel::EachValidator + FORBIDDEN = -1 + + def self.supported_sizes(type) + Gitlab::SSHPublicKey.supported_sizes(type) + end + + def self.supported_key_restrictions(type) + [0, *supported_sizes(type), FORBIDDEN] + end + + def validate_each(record, attribute, value) + unless valid_restriction?(value) + record.errors.add(attribute, "must be forbidden, allowed, or one of these sizes: #{supported_sizes_message}") + end + end + + private + + def supported_sizes_message + sizes = self.class.supported_sizes(options[:type]) + sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + end + + def valid_restriction?(value) + choices = self.class.supported_key_restrictions(options[:type]) + choices.include?(value) + end +end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 959af5c0d13..a010b4691bf 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -7,15 +7,15 @@ = f.label :default_branch_protection, class: 'control-label col-sm-2' .col-sm-10 = f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control' - .form-group.project-visibility-level-holder + .form-group.visibility-level-setting = f.label :default_project_visibility, class: 'control-label col-sm-2' .col-sm-10 = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) - .form-group.project-visibility-level-holder + .form-group.visibility-level-setting = f.label :default_snippet_visibility, class: 'control-label col-sm-2' .col-sm-10 = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new) - .form-group.project-visibility-level-holder + .form-group.visibility-level-setting = f.label :default_group_visibility, class: 'control-label col-sm-2' .col-sm-10 = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new) @@ -42,12 +42,7 @@ = link_to "(?)", help_page_path("integration/bitbucket") and GitLab.com = link_to "(?)", help_page_path("integration/gitlab") - .form-group - %label.control-label.col-sm-2 Enabled Git access protocols - .col-sm-10 - = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') - %span.help-block#clone-protocol-help - Allow only the selected protocols to be used for Git access. + .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -55,6 +50,20 @@ = f.check_box :project_export_enabled Project export enabled + .form-group + %label.control-label.col-sm-2 Enabled Git access protocols + .col-sm-10 + = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') + %span.help-block#clone-protocol-help + Allow only the selected protocols to be used for Git access. + + - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| + - field_name = :"#{type}_key_restriction" + .form-group + = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2' + .col-sm-10 + = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' + %fieldset %legend Account and Limit Settings .form-group @@ -153,7 +162,7 @@ .checkbox = f.label :password_authentication_enabled do = f.check_box :password_authentication_enabled - Password authentication enabled + Sign-in enabled - if omniauth_enabled? && button_based_providers.any? .form-group = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2' @@ -530,24 +539,27 @@ .help-block If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. - %fieldset - %legend Koding - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :koding_enabled do - = f.check_box :koding_enabled - Enable Koding - .form-group - = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' - .help-block - Koding has integration enabled out of the box for the - %strong gitlab - team, and you need to provide that team's URL here. Learn more in the - = succeed "." do - = link_to "Koding administration documentation", help_page_path("administration/integration/koding") + - if koding_enabled? + %fieldset + %legend Koding + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :koding_enabled do + = f.check_box :koding_enabled + Enable Koding + .help-block + Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. + .form-group + = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' + .help-block + Koding has integration enabled out of the box for the + %strong gitlab + team, and you need to provide that team's URL here. Learn more in the + = succeed "." do + = link_to "Koding administration documentation", help_page_path("administration/integration/koding") %fieldset %legend PlantUML diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index dff549f502c..c2151710884 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -31,3 +31,7 @@ = link_to admin_cohorts_path, title: 'Cohorts' do %span Cohorts + = nav_link(controller: :conversational_development_index) do + = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do + %span + ConvDev Index diff --git a/app/views/admin/monitoring/_head.html.haml b/app/views/admin/monitoring/_head.html.haml index 901e30275fd..b3530915068 100644 --- a/app/views/admin/monitoring/_head.html.haml +++ b/app/views/admin/monitoring/_head.html.haml @@ -3,10 +3,6 @@ = render 'shared/nav_scroll' .nav-links.sub-nav.scrolling-tabs %ul{ class: (container_class) } - = nav_link(controller: :conversational_development_index) do - = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do - %span - ConvDev Index = nav_link(controller: :system_info) do = link_to admin_system_info_path, title: 'System Info' do %span diff --git a/app/views/ci/lints/_create.html.haml b/app/views/ci/lints/_create.html.haml index c91602fcff7..30bf1384b22 100644 --- a/app/views/ci/lints/_create.html.haml +++ b/app/views/ci/lints/_create.html.haml @@ -22,10 +22,10 @@ %b Tag list: = build[:tag_list].to_a.join(", ") %br - %b Refs only: + %b Only policy: = @jobs[build[:name].to_sym][:only].to_a.join(", ") %br - %b Refs except: + %b Except policy: = @jobs[build[:name].to_sym][:except].to_a.join(", ") %br %b Environment: diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index 8ed23ac4919..dcfb7f0c32d 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -6,14 +6,14 @@ - tooltip = "#{subject.name} - #{status.label}" - if status.has_details? - = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip } do + = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do %span{ class: klass }= custom_icon(status.icon) %span.ci-build-text= subject.name - else - .mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip } } + .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } %span{ class: klass }= custom_icon(status.icon) %span.ci-build-text= subject.name - if status.has_action? - = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title } do + = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do = custom_icon(status.action_icon) diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml index c1dabeed387..25e90924413 100644 --- a/app/views/discussions/_headline.html.haml +++ b/app/views/discussions/_headline.html.haml @@ -5,7 +5,7 @@ by = link_to_member(@project, discussion.resolved_by, avatar: false) = time_ago_with_tooltip(discussion.resolved_at, placement: "bottom") -- elsif discussion.last_updated_at != discussion.created_at +- elsif discussion.updated? .discussion-headline-light.js-discussion-headline Last updated - if discussion.last_updated_by diff --git a/app/views/feature_highlight/_issue_boards.svg b/app/views/feature_highlight/_issue_boards.svg new file mode 100644 index 00000000000..1522c9d51c9 --- /dev/null +++ b/app/views/feature_highlight/_issue_boards.svg @@ -0,0 +1,98 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="214" height="102" viewBox="0 0 214 102" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <path id="b" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,27 C48,28.1045695 47.1045695,29 46,29 L2,29 C0.8954305,29 1.3527075e-16,28.1045695 0,27 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="a" width="102.1%" height="106.9%" x="-1%" y="-1.7%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="d" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="c" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="e" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/> + <path id="h" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="g" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="j" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="i" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="l" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="k" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="n" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="m" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="p" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="o" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + </defs> + <g fill="none" fill-rule="evenodd"> + <path fill="#D6D4DE" d="M14,21 L62,21 C64.7614237,21 67,23.2385763 67,26 L67,112 C67,114.761424 64.7614237,117 62,117 L14,117 C11.2385763,117 9,114.761424 9,112 L9,26 C9,23.2385763 11.2385763,21 14,21 Z"/> + <g transform="translate(11 23)"> + <path fill="#FFFFFF" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/> + <path fill="#FC6D26" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z"/> + <g transform="translate(5 10)"> + <use fill="black" filter="url(#a)" xlink:href="#b"/> + <use fill="#F9F9F9" xlink:href="#b"/> + </g> + <g transform="translate(5 42)"> + <use fill="black" filter="url(#c)" xlink:href="#d"/> + <use fill="#FEF0E8" xlink:href="#d"/> + <path fill="#FEE1D3" d="M9,8 L33,8 C34.1045695,8 35,8.8954305 35,10 C35,11.1045695 34.1045695,12 33,12 L9,12 C7.8954305,12 7,11.1045695 7,10 C7,8.8954305 7.8954305,8 9,8 Z"/> + <path fill="#FDC4A8" d="M9,17 L17,17 C18.1045695,17 19,17.8954305 19,19 C19,20.1045695 18.1045695,21 17,21 L9,21 C7.8954305,21 7,20.1045695 7,19 C7,17.8954305 7.8954305,17 9,17 Z"/> + <path fill="#FC6D26" d="M24,17 L32,17 C33.1045695,17 34,17.8954305 34,19 C34,20.1045695 33.1045695,21 32,21 L24,21 C22.8954305,21 22,20.1045695 22,19 C22,17.8954305 22.8954305,17 24,17 Z"/> + </g> + </g> + <path fill="#D6D4DE" d="M148,26 L196,26 C198.761424,26 201,28.2385763 201,31 L201,117 C201,119.761424 198.761424,122 196,122 L148,122 C145.238576,122 143,119.761424 143,117 L143,31 C143,28.2385763 145.238576,26 148,26 Z"/> + <g transform="translate(145 28)"> + <mask id="f" fill="white"> + <use xlink:href="#e"/> + </mask> + <use fill="#FFFFFF" xlink:href="#e"/> + <path fill="#FC6D26" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z" mask="url(#f)"/> + <g transform="translate(5 10)"> + <use fill="black" filter="url(#g)" xlink:href="#h"/> + <use fill="#F9F9F9" xlink:href="#h"/> + </g> + <g transform="translate(5 42)"> + <use fill="black" filter="url(#i)" xlink:href="#j"/> + <use fill="#FEF0E8" xlink:href="#j"/> + <path fill="#FEE1D3" d="M9 8L33 8C34.1045695 8 35 8.8954305 35 10 35 11.1045695 34.1045695 12 33 12L9 12C7.8954305 12 7 11.1045695 7 10 7 8.8954305 7.8954305 8 9 8zM9 17L13 17C14.1045695 17 15 17.8954305 15 19 15 20.1045695 14.1045695 21 13 21L9 21C7.8954305 21 7 20.1045695 7 19 7 17.8954305 7.8954305 17 9 17z"/> + <path fill="#FC6D26" d="M20,17 L24,17 C25.1045695,17 26,17.8954305 26,19 C26,20.1045695 25.1045695,21 24,21 L20,21 C18.8954305,21 18,20.1045695 18,19 C18,17.8954305 18.8954305,17 20,17 Z"/> + <path fill="#FDC4A8" d="M31,17 L35,17 C36.1045695,17 37,17.8954305 37,19 C37,20.1045695 36.1045695,21 35,21 L31,21 C29.8954305,21 29,20.1045695 29,19 C29,17.8954305 29.8954305,17 31,17 Z"/> + </g> + </g> + <path fill="#D6D4DE" d="M81,14 L129,14 C131.761424,14 134,16.2385763 134,19 L134,105 C134,107.761424 131.761424,110 129,110 L81,110 C78.2385763,110 76,107.761424 76,105 L76,19 C76,16.2385763 78.2385763,14 81,14 Z"/> + <g transform="translate(78 16)"> + <path fill="#FFFFFF" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/> + <g transform="translate(5 10)"> + <use fill="black" filter="url(#k)" xlink:href="#l"/> + <use fill="#EFEDF8" xlink:href="#l"/> + <path fill="#E1DBF1" d="M9,8 L33,8 C34.1045695,8 35,8.8954305 35,10 C35,11.1045695 34.1045695,12 33,12 L9,12 C7.8954305,12 7,11.1045695 7,10 C7,8.8954305 7.8954305,8 9,8 Z"/> + <path fill="#6B4FBB" d="M9,17 L13,17 C14.1045695,17 15,17.8954305 15,19 C15,20.1045695 14.1045695,21 13,21 L9,21 C7.8954305,21 7,20.1045695 7,19 C7,17.8954305 7.8954305,17 9,17 Z"/> + <path fill="#C3B8E3" d="M20,17 L28,17 C29.1045695,17 30,17.8954305 30,19 C30,20.1045695 29.1045695,21 28,21 L20,21 C18.8954305,21 18,20.1045695 18,19 C18,17.8954305 18.8954305,17 20,17 Z"/> + </g> + <g transform="translate(5 42)"> + <use fill="black" filter="url(#m)" xlink:href="#n"/> + <use fill="#F9F9F9" xlink:href="#n"/> + </g> + <g transform="translate(5 74)"> + <rect width="34" height="4" x="7" y="7" fill="#E1DBF1" rx="2"/> + <use fill="black" filter="url(#o)" xlink:href="#p"/> + <use fill="#F9F9F9" xlink:href="#p"/> + </g> + <path fill="#6B4FBB" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z"/> + </g> + </g> +</svg> diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 12bc092d216..837ef385dd5 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -12,6 +12,8 @@ - content_for :breadcrumbs_extra do = link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do = icon('rss') + %span.icon-label + Subscribe = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues - if group_issues_exists diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index 54b1b7a734a..23b1a22240f 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -1,4 +1,4 @@ = render "header_title" = render 'shared/milestones/top', milestone: @milestone, group: @group -= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.is_legacy_group_milestone? += render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.legacy_group_milestone? = render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102 diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index b32cfe158bb..0d6760e7b8f 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -42,21 +42,21 @@ = link_to sherlock_transactions_path, title: 'Sherlock Transactions', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('tachometer fw') - %li + %li.user-counter = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('issues') - issues_count = assigned_issuables_count(:issues) %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) - %li + %li.user-counter = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - merge_requests_count = assigned_issuables_count(:merge_requests) %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } = number_with_delimiter(merge_requests_count) - %li + %li.user-counter = link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('check-circle fw') + = custom_icon('todo_done') %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) } = todos_count_format(todos_pending_count) %li.header-user.dropdown @@ -74,8 +74,6 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation") %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml index 2c1c23d6ea9..61b71c091be 100644 --- a/app/views/layouts/header/_new.html.haml +++ b/app/views/layouts/header/_new.html.haml @@ -4,7 +4,7 @@ .header-content .title-container %h1.title - = link_to root_path, title: 'Dashboard' do + = link_to root_path, title: 'Dashboard', id: 'logo' do = brand_header_logo %span.logo-text.hidden-xs = render 'shared/logo_type.svg' @@ -16,47 +16,35 @@ .navbar-collapse.collapse %ul.nav.navbar-nav + - if current_user + = render 'layouts/header/new_dropdown' %li.hidden-sm.hidden-xs = render 'layouts/search' unless current_controller?(:search) %li.visible-sm-inline-block.visible-xs-inline-block = link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('search') - if current_user - - if session[:impersonator_id] - %li.impersonation - = link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do - = icon('user-secret fw') - - if current_user.admin? - %li - = link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('wrench fw') - = render 'layouts/header/new_dropdown' - - if Gitlab::Sherlock.enabled? - %li - = link_to sherlock_transactions_path, title: 'Sherlock Transactions', - data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('tachometer fw') - %li - = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + %li.user-counter + = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('issues') - issues_count = assigned_issuables_count(:issues) %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) - %li - = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + %li.user-counter + = link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - merge_requests_count = assigned_issuables_count(:merge_requests) %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } = number_with_delimiter(merge_requests_count) - %li + %li.user-counter = link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('check-circle fw') + = custom_icon('todo_done') %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) } = todos_count_format(todos_pending_count) %li.header-user.dropdown - = link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do - = image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar" - = icon('chevron-down') + = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do + = image_tag avatar_icon(current_user, 23), width: 23, height: 23, class: "header-user-avatar" + = custom_icon('caret_down') .dropdown-menu-nav.dropdown-menu-align-right %ul %li.current-user @@ -68,15 +56,20 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation") + - if current_user + %li + = link_to "Help", help_path %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" + - if session[:impersonator_id] + %li.impersonation + = link_to admin_impersonation_path, class: 'impersonation-btn', method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = icon('user-secret') - else %li %div - = link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in btn-success' + = link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in' %button.navbar-toggle.hidden-sm.hidden-md.hidden-lg{ type: 'button' } %span.sr-only Toggle navigation diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 9da739b0974..9cf2739b368 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -1,11 +1,11 @@ %li.header-new.dropdown = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do - if show_new_nav? - = icon('plus') - = icon('chevron-down') + = custom_icon('plus_square') + = custom_icon('caret_down') - else = icon('plus fw') - = icon('caret-down') + = custom_icon('caret_down') .dropdown-menu-nav.dropdown-menu-align-right %ul - if @group&.persisted? diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index 4db84771f4e..653452871a0 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -1,8 +1,9 @@ - breadcrumb_link = breadcrumb_title_link +- container = @no_breadcrumb_container ? 'container-fluid' : container_class - hide_top_links = @hide_top_links || false %nav.breadcrumbs{ role: "navigation" } - .breadcrumbs-container{ class: [container_class, @content_class] } + .breadcrumbs-container{ class: [container, @content_class] } - if defined?(@new_sidebar) = button_tag class: 'toggle-mobile-nav', type: 'button' do %span.sr-only Open sidebar diff --git a/app/views/layouts/nav/_new_admin_sidebar.html.haml b/app/views/layouts/nav/_new_admin_sidebar.html.haml index 1c3fd4a082c..3b53117deb6 100644 --- a/app/views/layouts/nav/_new_admin_sidebar.html.haml +++ b/app/views/layouts/nav/_new_admin_sidebar.html.haml @@ -7,7 +7,7 @@ .sidebar-context-title Admin Area %ul.sidebar-top-level-items = nav_link(controller: %w(dashboard admin projects users groups jobs runners cohorts), html_options: {class: 'home'}) do - = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do + = sidebar_link admin_root_path, title: _('Overview'), css: 'shortcuts-tree' do .nav-icon-container = custom_icon('overview') %span.nav-item-name @@ -42,19 +42,19 @@ = link_to admin_cohorts_path, title: 'Cohorts' do %span Cohorts + = nav_link(controller: :conversational_development_index) do + = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do + %span + ConvDev Index = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do - = link_to admin_conversational_development_index_path, title: 'Monitoring' do + = sidebar_link admin_conversational_development_index_path, title: _('Monitoring') do .nav-icon-container = custom_icon('monitoring') %span.nav-item-name Monitoring %ul.sidebar-sub-level-items - = nav_link(controller: :conversational_development_index) do - = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do - %span - ConvDev Index = nav_link(controller: :system_info) do = link_to admin_system_info_path, title: 'System Info' do %span @@ -77,27 +77,28 @@ Requests Profiles = nav_link(controller: :broadcast_messages) do - = link_to admin_broadcast_messages_path, title: 'Messages' do + = sidebar_link admin_broadcast_messages_path, title: _('Messages') do .nav-icon-container = custom_icon('messages') %span.nav-item-name Messages + = nav_link(controller: [:hooks, :hook_logs]) do - = link_to admin_hooks_path, title: 'Hooks' do + = sidebar_link admin_hooks_path, title: _('Hooks') do .nav-icon-container = custom_icon('system_hooks') %span.nav-item-name System Hooks = nav_link(controller: :applications) do - = link_to admin_applications_path, title: 'Applications' do + = sidebar_link admin_applications_path, title: _('Applications') do .nav-icon-container = custom_icon('applications') %span.nav-item-name Applications = nav_link(controller: :abuse_reports) do - = link_to admin_abuse_reports_path, title: "Abuse Reports" do + = sidebar_link admin_abuse_reports_path, title: _("Abuse Reports") do .nav-icon-container = custom_icon('abuse_reports') %span.nav-item-name @@ -106,43 +107,42 @@ - if akismet_enabled? = nav_link(controller: :spam_logs) do - = link_to admin_spam_logs_path, title: "Spam Logs" do + = sidebar_link admin_spam_logs_path, title: _("Spam Logs") do .nav-icon-container = custom_icon('spam_logs') %span.nav-item-name Spam Logs = nav_link(controller: :deploy_keys) do - = link_to admin_deploy_keys_path, title: 'Deploy Keys' do + = sidebar_link admin_deploy_keys_path, title: _('Deploy Keys') do .nav-icon-container = custom_icon('key') %span.nav-item-name Deploy Keys = nav_link(controller: :services) do - = link_to admin_application_settings_services_path, title: 'Service Templates' do + = sidebar_link admin_application_settings_services_path, title: _('Service Templates') do .nav-icon-container = custom_icon('service_templates') %span.nav-item-name Service Templates = nav_link(controller: :labels) do - = link_to admin_labels_path, title: 'Labels' do + = sidebar_link admin_labels_path, title: _('Labels') do .nav-icon-container = custom_icon('labels') %span.nav-item-name Labels = nav_link(controller: :appearances) do - = link_to admin_appearances_path, title: 'Appearances' do + = sidebar_link admin_appearances_path, title: _('Appearances') do .nav-icon-container = custom_icon('appearance') %span.nav-item-name Appearance - %li.divider = nav_link(controller: :application_settings) do - = link_to admin_application_settings_path, title: 'Settings' do + = sidebar_link admin_application_settings_path, title: _('Settings') do .nav-icon-container = custom_icon('settings') %span.nav-item-name diff --git a/app/views/layouts/nav/_new_dashboard.html.haml b/app/views/layouts/nav/_new_dashboard.html.haml index cfdfcbebc9f..8a39c4d775f 100644 --- a/app/views/layouts/nav/_new_dashboard.html.haml +++ b/app/views/layouts/nav/_new_dashboard.html.haml @@ -1,23 +1,38 @@ %ul.list-unstyled.navbar-sub-nav - = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "home"}) do - = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do + = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown" }) do + %a{ href: "#", data: { toggle: "dropdown" } } Projects + = custom_icon('caret_down') + .dropdown-menu.projects-dropdown-menu + = render "layouts/nav/projects_dropdown/show" - = nav_link(controller: ['dashboard/groups', 'explore/groups']) do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do Groups - = nav_link(path: 'dashboard#activity', html_options: { class: "hidden-xs hidden-sm" }) do + = nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do Activity - %li.dropdown + = nav_link(controller: 'dashboard/milestones', html_options: { class: "visible-lg" }) do + = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do + Milestones + + = nav_link(controller: 'dashboard/snippets', html_options: { class: "visible-lg" }) do + = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do + Snippets + + %li.dropdown.hidden-lg %a{ href: "#", data: { toggle: "dropdown" } } More - = icon("chevron-down", class: "dropdown-chevron") + = custom_icon('caret_down') .dropdown-menu %ul - = nav_link(path: 'dashboard#activity', html_options: { class: "visible-xs visible-sm" }) do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "visible-xs" }) do + = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do + Groups + + = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, title: 'Activity' do Activity @@ -28,6 +43,20 @@ = nav_link(controller: 'dashboard/snippets') do = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do Snippets - %li.divider - %li - = link_to "Help", help_path, title: 'About GitLab CE' + + -# Shortcut to Dashboard > Projects + %li.hidden + = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do + Projects + + - if current_user.admin? || Gitlab::Sherlock.enabled? + %li.line-separator.hidden-xs + - if current_user.admin? + = nav_link(controller: 'admin/dashboard') do + = link_to admin_root_path, class: 'admin-icon', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = icon('wrench fw') + - if Gitlab::Sherlock.enabled? + %li + = link_to sherlock_transactions_path, class: 'admin-icon', title: 'Sherlock Transactions', + data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = icon('tachometer fw') diff --git a/app/views/layouts/nav/_new_explore.html.haml b/app/views/layouts/nav/_new_explore.html.haml index 40385f251e3..cd1c39f3226 100644 --- a/app/views/layouts/nav/_new_explore.html.haml +++ b/app/views/layouts/nav/_new_explore.html.haml @@ -5,15 +5,8 @@ = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do Groups - %li.dropdown - %a{ href: "#", data: { toggle: "dropdown" } } - More - = icon("chevron-down", class: "dropdown-chevron") - .dropdown-menu - %ul - = nav_link(controller: :snippets) do - = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do - Snippets - %li.divider - %li - = link_to "Help", help_path, title: 'About GitLab CE' + = nav_link(controller: :snippets) do + = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do + Snippets + %li + = link_to "Help", help_path, title: 'About GitLab CE' diff --git a/app/views/layouts/nav/_new_group_sidebar.html.haml b/app/views/layouts/nav/_new_group_sidebar.html.haml index d90aea2e361..5a1511b262f 100644 --- a/app/views/layouts/nav/_new_group_sidebar.html.haml +++ b/app/views/layouts/nav/_new_group_sidebar.html.haml @@ -8,7 +8,7 @@ = @group.name %ul.sidebar-top-level-items = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: 'home' }) do - = link_to group_path(@group), title: 'Group overview' do + = sidebar_link group_path(@group), title: _('Group overview') do .nav-icon-container = custom_icon('project') %span.nav-item-name @@ -26,7 +26,7 @@ Activity = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do - = link_to issues_group_path(@group), title: 'Issues' do + = sidebar_link issues_group_path(@group), title: _('Issues') do .nav-icon-container = custom_icon('issues') %span.nav-item-name @@ -51,7 +51,7 @@ Milestones = nav_link(path: 'groups#merge_requests') do - = link_to merge_requests_group_path(@group), title: 'Merge Requests' do + = sidebar_link merge_requests_group_path(@group), title: _('Merge Requests') do .nav-icon-container = custom_icon('mr_bold') %span.nav-item-name @@ -59,14 +59,14 @@ Merge Requests %span.badge.count= number_with_delimiter(merge_requests.count) = nav_link(path: 'group_members#index') do - = link_to group_group_members_path(@group), title: 'Members' do + = sidebar_link group_group_members_path(@group), title: _('Members') do .nav-icon-container = custom_icon('members') %span.nav-item-name Members - if current_user && can?(current_user, :admin_group, @group) = nav_link(path: %w[groups#projects groups#edit ci_cd#show]) do - = link_to edit_group_path(@group), title: 'Settings' do + = sidebar_link edit_group_path(@group), title: _('Settings') do .nav-icon-container = custom_icon('settings') %span.nav-item-name diff --git a/app/views/layouts/nav/_new_profile_sidebar.html.haml b/app/views/layouts/nav/_new_profile_sidebar.html.haml index 85b2c7630c8..ccb6d1492f1 100644 --- a/app/views/layouts/nav/_new_profile_sidebar.html.haml +++ b/app/views/layouts/nav/_new_profile_sidebar.html.haml @@ -7,76 +7,76 @@ .sidebar-context-title User Settings %ul.sidebar-top-level-items = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do - = link_to profile_path, title: 'Profile Settings' do + = sidebar_link profile_path, title: _('Profile Settings') do .nav-icon-container = custom_icon('profile') %span.nav-item-name Profile = nav_link(controller: [:accounts, :two_factor_auths]) do - = link_to profile_account_path, title: 'Account' do + = sidebar_link profile_account_path, title: _('Account') do .nav-icon-container = custom_icon('account') %span.nav-item-name Account - if current_application_settings.user_oauth_applications? = nav_link(controller: 'oauth/applications') do - = link_to applications_profile_path, title: 'Applications' do + = sidebar_link applications_profile_path, title: _('Applications') do .nav-icon-container = custom_icon('applications') %span.nav-item-name Applications = nav_link(controller: :chat_names) do - = link_to profile_chat_names_path, title: 'Chat' do + = sidebar_link profile_chat_names_path, title: _('Chat') do .nav-icon-container = custom_icon('chat') %span.nav-item-name Chat = nav_link(controller: :personal_access_tokens) do - = link_to profile_personal_access_tokens_path, title: 'Access Tokens' do + = sidebar_link profile_personal_access_tokens_path, title: _('Access Tokens') do .nav-icon-container = custom_icon('access_tokens') %span.nav-item-name Access Tokens = nav_link(controller: :emails) do - = link_to profile_emails_path, title: 'Emails' do + = sidebar_link profile_emails_path, title: _('Emails') do .nav-icon-container = custom_icon('emails') %span.nav-item-name Emails - unless current_user.ldap_user? = nav_link(controller: :passwords) do - = link_to edit_profile_password_path, title: 'Password' do + = sidebar_link edit_profile_password_path, title: _('Password') do .nav-icon-container = custom_icon('lock') %span.nav-item-name Password = nav_link(controller: :notifications) do - = link_to profile_notifications_path, title: 'Notifications' do + = sidebar_link profile_notifications_path, title: _('Notifications') do .nav-icon-container = custom_icon('notifications') %span.nav-item-name Notifications = nav_link(controller: :keys) do - = link_to profile_keys_path, title: 'SSH Keys' do + = sidebar_link profile_keys_path, title: _('SSH Keys') do .nav-icon-container = custom_icon('key') %span.nav-item-name SSH Keys = nav_link(controller: :gpg_keys) do - = link_to profile_gpg_keys_path, title: 'GPG Keys' do + = sidebar_link profile_gpg_keys_path, title: _('GPG Keys') do .nav-icon-container = custom_icon('key_2') %span.nav-item-name GPG Keys = nav_link(controller: :preferences) do - = link_to profile_preferences_path, title: 'Preferences' do + = sidebar_link profile_preferences_path, title: _('Preferences') do .nav-icon-container = custom_icon('preferences') %span.nav-item-name Preferences = nav_link(path: 'profiles#audit_log') do - = link_to audit_log_profile_path, title: 'Authentication log' do + = sidebar_link audit_log_profile_path, title: _('Authentication log') do .nav-icon-container = custom_icon('authentication_log') %span.nav-item-name diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml index 341943cf833..760c4c97c33 100644 --- a/app/views/layouts/nav/_new_project_sidebar.html.haml +++ b/app/views/layouts/nav/_new_project_sidebar.html.haml @@ -9,7 +9,7 @@ = @project.name %ul.sidebar-top-level-items = nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do - = link_to project_path(@project), title: 'Project overview', class: 'shortcuts-project' do + = sidebar_link project_path(@project), title: _('Project overview'), css: 'shortcuts-project' do .nav-icon-container = custom_icon('project') %span.nav-item-name @@ -31,7 +31,7 @@ - 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 - = link_to project_tree_path(@project), title: 'Repository', class: 'shortcuts-tree' do + = sidebar_link project_tree_path(@project), title: _('Repository'), css: 'shortcuts-tree' do .nav-icon-container = custom_icon('doc_text') %span.nav-item-name @@ -72,7 +72,7 @@ - if project_nav_tab? :container_registry = nav_link(controller: %w[projects/registry/repositories]) do - = link_to project_container_registry_index_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do + = sidebar_link project_container_registry_index_path(@project), title: _('Container Registry'), css: 'shortcuts-container-registry' do .nav-icon-container = custom_icon('container_registry') %span.nav-item-name @@ -80,7 +80,7 @@ - if project_nav_tab? :issues = nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do - = link_to project_issues_path(@project), title: 'Issues', class: 'shortcuts-issues' do + = sidebar_link project_issues_path(@project), title: _('Issues'), css: 'shortcuts-issues' do .nav-icon-container = custom_icon('issues') %span.nav-item-name @@ -99,6 +99,20 @@ = link_to project_boards_path(@project), title: 'Board' do %span Board + .feature-highlight.js-feature-highlight{ disabled: true, data: { trigger: 'manual', container: 'body', toggle: 'popover', placement: 'right', highlight: 'issue-boards' } } + .feature-highlight-popover-content + = render 'feature_highlight/issue_boards.svg' + .feature-highlight-popover-sub-content + %span= _('Use') + = link_to 'Issue Boards', project_boards_path(@project) + %span= _('to create customized software development workflows like') + %strong= _('Scrum') + %span= _('or') + %strong= _('Kanban') + %hr + %button.btn-link.dismiss-feature-highlight{ type: 'button' } + %span= _("Got it! Don't show this again") + = custom_icon('thumbs_up') = nav_link(controller: :labels) do = link_to project_labels_path(@project), title: 'Labels' do @@ -112,7 +126,7 @@ - if project_nav_tab? :merge_requests = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do - = link_to project_merge_requests_path(@project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do + = sidebar_link project_merge_requests_path(@project), title: _('Merge Requests'), css: 'shortcuts-merge_requests' do .nav-icon-container = custom_icon('mr_bold') %span.nav-item-name @@ -122,7 +136,7 @@ - if project_nav_tab? :pipelines = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts]) do - = link_to project_pipelines_path(@project), title: 'CI / CD', class: 'shortcuts-pipelines' do + = sidebar_link project_pipelines_path(@project), title: _('CI / CD'), css: 'shortcuts-pipelines' do .nav-icon-container = custom_icon('pipeline') %span.nav-item-name @@ -161,7 +175,7 @@ - if project_nav_tab? :wiki = nav_link(controller: :wikis) do - = link_to get_project_wiki_path(@project), title: 'Wiki', class: 'shortcuts-wiki' do + = sidebar_link get_project_wiki_path(@project), title: _('Wiki'), css: 'shortcuts-wiki' do .nav-icon-container = custom_icon('wiki') %span.nav-item-name @@ -169,7 +183,7 @@ - if project_nav_tab? :snippets = nav_link(controller: :snippets) do - = link_to project_snippets_path(@project), title: 'Snippets', class: 'shortcuts-snippets' do + = sidebar_link project_snippets_path(@project), title: _('Snippets'), css: 'shortcuts-snippets' do .nav-icon-container = custom_icon('snippets') %span.nav-item-name @@ -177,7 +191,7 @@ - if project_nav_tab? :settings = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do - = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do + = sidebar_link edit_project_path(@project), title: _('Settings'), css: 'shortcuts-tree' do .nav-icon-container = custom_icon('settings') %span.nav-item-name @@ -208,7 +222,7 @@ = link_to project_settings_ci_cd_path(@project), title: 'CI / CD' do %span CI / CD - - if Gitlab.config.pages.enabled + - if @project.pages_available? = nav_link(controller: :pages) do = link_to project_pages_path(@project), title: 'Pages' do %span diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index 26d9640e98a..448f6abedf2 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -29,7 +29,7 @@ = link_to profile_emails_path, title: 'Emails' do %span Emails - - if current_user.allow_password_authentication? + - unless current_user.ldap_user? = nav_link(controller: :passwords) do = link_to edit_profile_password_path, title: 'Password' do %span diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml new file mode 100644 index 00000000000..a7370180bf6 --- /dev/null +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -0,0 +1,15 @@ +- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: @project.web_url, avatar_url: @project.avatar_url } if @project&.persisted? +.projects-dropdown-container + .project-dropdown-sidebar + %ul + = nav_link(path: 'dashboard/projects#index') do + = link_to dashboard_projects_path do + = _('Your projects') + = nav_link(path: 'projects#starred') do + = link_to starred_dashboard_projects_path do + = _('Starred projects') + = nav_link(path: 'projects#trending') do + = link_to explore_root_path do + = _('Explore projects') + .project-dropdown-content + #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } } diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 54d56e9b873..d6db85ee87a 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -14,12 +14,4 @@ :javascript window.uploads_path = "#{project_uploads_path(project)}"; -- content_for :header_content do - .js-dropdown-menu-projects - .dropdown-menu.dropdown-select.dropdown-menu-projects - = dropdown_title("Go to a project") - = dropdown_filter("Search your projects") - = dropdown_content - = dropdown_loading - = render template: "layouts/application" diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 720a97cddb7..8dbb8aef31b 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -12,7 +12,7 @@ Add a GPG key %p.profile-settings-content Before you can add a GPG key you need to - = link_to 'generate it.', help_page_path('user/project/gpg_signed_commits/index.md') + = link_to 'generate it.', help_page_path('user/project/repository/gpg_signed_commits/index.md') = render 'form' %hr %h5 diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index d2a60ac2867..103446243e5 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -1,6 +1,12 @@ %li.key-list-item .pull-left.append-right-10 - = icon 'key', class: "settings-list-icon hidden-xs" + - if key.valid? + = icon 'key', class: 'settings-list-icon hidden-xs' + - else + = icon 'exclamation-triangle', class: 'settings-list-icon hidden-xs has-tooltip', + title: key.errors.full_messages.join(', ') + + .key-list-item-info = link_to path_to_key(key, is_admin), class: "title" do = key.title diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index d44603c638c..77521417f47 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -16,6 +16,7 @@ %strong= @key.last_used_at.try(:to_s, :medium) || 'N/A' .col-md-8 + = form_errors(@key, type: 'key') unless @key.valid? %p %span.light Fingerprint: %code.key-fingerprint= @key.fingerprint diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index f08dcc0c242..9e7fe556d88 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -18,26 +18,6 @@ = scheme.name .col-sm-12 %hr - .col-lg-4.profile-settings-sidebar#new-navigation - %h4.prepend-top-0 - New Navigation - %p - This setting allows you to turn on or off the new upcoming navigation concept. - .col-lg-8.syntax-theme - .nav-wip - %p - The new navigation is currently a work-in-progress concept and is currently only usable on wide-screens. There are a number of improvements that we are working on in order to further refine our navigation. - %p - %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/32794', target: 'blank' } Learn more - about the improvements that are coming soon! - = label_tag do - .preview= image_tag "old_nav.png" - %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_nav", checked: !show_new_nav? } - Old - = label_tag do - .preview= image_tag "new_nav.png" - %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_nav", checked: show_new_nav? } - New .col-sm-12 %hr .col-lg-4.profile-settings-sidebar diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 97041b87c48..71424593f2e 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -1,10 +1,5 @@ - referenced_users = local_assigns.fetch(:referenced_users, nil) -- if defined?(@issue) && @issue.confidential? - .confidential-issue-warning - = confidential_icon(@issue) - %span This is a confidential issue. Your comment will not be visible to the public. - .md-area .md-header %ul.nav-links.clearfix diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index 2076e46fde8..5354ec8522e 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -1,3 +1,4 @@ +- @no_breadcrumb_container = true - @no_container = true - @content_class = "issue-boards-content" - page_title "Boards" diff --git a/app/views/projects/boards/components/sidebar/_due_date.html.haml b/app/views/projects/boards/components/sidebar/_due_date.html.haml index f44a9d49a54..e8394eab213 100644 --- a/app/views/projects/boards/components/sidebar/_due_date.html.haml +++ b/app/views/projects/boards/components/sidebar/_due_date.html.haml @@ -3,7 +3,7 @@ Due date - if can?(current_user, :admin_issue, @project) = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" + = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" .value .value-content %span.no-value{ "v-if" => "!issue.dueDate" } diff --git a/app/views/projects/boards/components/sidebar/_labels.html.haml b/app/views/projects/boards/components/sidebar/_labels.html.haml index 7d0c35fe183..6b389736e8b 100644 --- a/app/views/projects/boards/components/sidebar/_labels.html.haml +++ b/app/views/projects/boards/components/sidebar/_labels.html.haml @@ -3,7 +3,7 @@ Labels - if can?(current_user, :admin_issue, @project) = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" + = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" .value.issuable-show-labels %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } None diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml index 002e9994ee0..a1ddb261ea3 100644 --- a/app/views/projects/boards/components/sidebar/_milestone.html.haml +++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml @@ -3,7 +3,7 @@ Milestone - if can?(current_user, :admin_issue, @project) = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" + = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" .value %span.no-value{ "v-if" => "!issue.milestone" } None diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml deleted file mode 100644 index 3a73aae9d95..00000000000 --- a/app/views/projects/commit/_invalid_signature_badge.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- title = capture do - .gpg-popover-icon.invalid - = render 'shared/icons/icon_status_notfound_borderless.svg' - %div - This commit was signed with an <strong>unverified</strong> signature. - -- locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] } - -= render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml new file mode 100644 index 00000000000..80eca96f7ce --- /dev/null +++ b/app/views/projects/commit/_other_user_signature_badge.html.haml @@ -0,0 +1,6 @@ +- title = capture do + This commit was signed with a different user's verified signature. + +- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml new file mode 100644 index 00000000000..e737de48e22 --- /dev/null +++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml @@ -0,0 +1,7 @@ +- title = capture do + This commit was signed with a verified signature, but the committer email + is <strong>not verified</strong> to belong to the same user. + +- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'icon_status_notfound_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index 60fa52557ef..145bc629380 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,5 +1,2 @@ - if signature - - if signature.valid_signature? - = render partial: 'projects/commit/valid_signature_badge', locals: { signature: signature } - - else - = render partial: 'projects/commit/invalid_signature_badge', locals: { signature: signature } + = render partial: "projects/commit/#{signature.verification_status}_signature_badge", locals: { signature: signature } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index a3783b31b86..edff018ba6d 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -1,18 +1,28 @@ -- css_classes = commit_signature_badge_classes(css_classes) +- signature = local_assigns.fetch(:signature) +- title = local_assigns.fetch(:title) +- label = local_assigns.fetch(:label) +- css_class = local_assigns.fetch(:css_class) +- icon = local_assigns.fetch(:icon) +- show_user = local_assigns.fetch(:show_user, false) + +- css_classes = commit_signature_badge_classes(css_class) - title = capture do .gpg-popover-status - = title + .gpg-popover-icon{ class: css_class } + = render "shared/icons/#{icon}.svg" + %div + = title - content = capture do - .clearfix - = content + - if show_user + .clearfix + = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature } GPG Key ID: %span.monospace= signature.gpg_key_primary_keyid - - = link_to('Learn more about signing commits', help_page_path('user/project/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') %button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } } = label diff --git a/app/views/projects/commit/_signature_badge_user.html.haml b/app/views/projects/commit/_signature_badge_user.html.haml new file mode 100644 index 00000000000..b20198e76db --- /dev/null +++ b/app/views/projects/commit/_signature_badge_user.html.haml @@ -0,0 +1,21 @@ +- gpg_key = signature.gpg_key +- user = gpg_key&.user +- user_name = signature.gpg_key_user_name +- user_email = signature.gpg_key_user_email + +- if user + = link_to user_path(user), class: 'gpg-popover-user-link' do + %div + = user_avatar_without_link(user: user, size: 32) + + %div + %strong= user.name + %div= user.to_reference +- else + = mail_to user_email do + %div + = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) + + %div + %strong= user_name + %div= user_email diff --git a/app/views/projects/commit/_unknown_key_signature_badge.html.haml b/app/views/projects/commit/_unknown_key_signature_badge.html.haml new file mode 100644 index 00000000000..75c5cf57bcc --- /dev/null +++ b/app/views/projects/commit/_unknown_key_signature_badge.html.haml @@ -0,0 +1 @@ += render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature } diff --git a/app/views/projects/commit/_unverified_key_signature_badge.html.haml b/app/views/projects/commit/_unverified_key_signature_badge.html.haml new file mode 100644 index 00000000000..75c5cf57bcc --- /dev/null +++ b/app/views/projects/commit/_unverified_key_signature_badge.html.haml @@ -0,0 +1 @@ += render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature } diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml new file mode 100644 index 00000000000..1af58027b83 --- /dev/null +++ b/app/views/projects/commit/_unverified_signature_badge.html.haml @@ -0,0 +1,6 @@ +- title = capture do + This commit was signed with an <strong>unverified</strong> signature. + +- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless' } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml deleted file mode 100644 index db1a41bbf64..00000000000 --- a/app/views/projects/commit/_valid_signature_badge.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -- title = capture do - .gpg-popover-icon.valid - = render 'shared/icons/icon_status_success_borderless.svg' - %div - This commit was signed with a <strong>verified</strong> signature. - -- content = capture do - - gpg_key = signature.gpg_key - - user = gpg_key&.user - - user_name = signature.gpg_key_user_name - - user_email = signature.gpg_key_user_email - - - if user - = link_to user_path(user), class: 'gpg-popover-user-link' do - %div - = user_avatar_without_link(user: user, size: 32) - - %div - %strong= gpg_key.user.name - %div @#{gpg_key.user.username} - - else - = mail_to user_email do - %div - = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) - - %div - %strong= user_name - %div= user_email - -- locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] } - -= render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml new file mode 100644 index 00000000000..423beba2120 --- /dev/null +++ b/app/views/projects/commit/_verified_signature_badge.html.haml @@ -0,0 +1,7 @@ +- title = capture do + This commit was signed with a <strong>verified</strong> signature and the + committer email is verified to belong to the same user. + +- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'icon_status_success_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 8b095f4ca10..483f28c74f2 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,7 +1,17 @@ +- @gfm_form = true + - content_for :note_actions do - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' -#notes - = render 'shared/notes/notes_with_form', :autocomplete => true +%section.js-vue-notes-event + #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json), + register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), + new_session_path: new_session_path(:user, redirect_to_referer: 'yes'), + markdown_docs_path: help_page_path('user/markdown'), + quick_actions_docs_path: help_page_path('user/project/quick_actions'), + notes_path: notes_url, + last_fetched_at: Time.now.to_i, + issue_data: serialize_issuable(@issue), + current_user_data: UserSerializer.new.represent(current_user).to_json } } diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index 34d5a3e1831..6fb5aa45166 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -4,4 +4,4 @@ = render 'shared/empty_states/issues' - if @issues.present? - = paginate @issues, theme: "gitlab" + = paginate @issues, theme: "gitlab", total_pages: @total_pages diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index de0f1de057d..fd7ff176c5e 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,6 +2,11 @@ - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'notes' + - can_update_issue = can?(current_user, :update_issue, @issue) - can_report_spam = @issue.submittable_as_spam_by?(current_user) @@ -23,7 +28,7 @@ = icon('eye-slash', class: 'is-confidential') = issuable_meta(@issue, @project, "Issue") - .issuable-actions + .issuable-actions.js-issuable-actions .clearfix.issue-btn-group.dropdown %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } Options @@ -36,8 +41,8 @@ - if @issue.author && current_user != @issue.author %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue)) - if can_update_issue - %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue' + %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - if can_report_spam %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' - if can_update_issue || can_report_spam @@ -74,7 +79,7 @@ .content-block.emoji-block .row - .col-sm-8 + .col-sm-8.js-issue-note-awards = render 'award_emoji/awards_block', awardable: @issue, inline: true .col-sm-4.new-branch-col = render 'new_branch' unless @issue.confidential? diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index f5d5bc7eda9..43e23bb2200 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -46,14 +46,14 @@ %span.build-light-text Token: #{@build.trigger_request.trigger.short_token} - - if @build.trigger_request.variables + - if @build.trigger_variables.any? %p %button.btn.group.btn-group-justified.reveal-variables Reveal Variables %dl.js-build-variables.trigger-build-variables.hide - - @build.trigger_request.variables.each do |key, value| - %dt.js-build-variable.trigger-build-variable= key - %dd.js-build-value.trigger-build-value= value + - @build.trigger_variables.each do |trigger_variable| + %dt.js-build-variable.trigger-build-variable= trigger_variable[:key] + %dd.js-build-value.trigger-build-value= trigger_variable[:value] %div{ class: (@build.pipeline.stages_count > 1 ? "block" : "block-last") } %p diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml index 4e97f74dd6a..bd6f1c05949 100644 --- a/app/views/projects/merge_requests/_merge_requests.html.haml +++ b/app/views/projects/merge_requests/_merge_requests.html.haml @@ -5,4 +5,4 @@ = render 'shared/empty_states/merge_requests' - if @merge_requests.present? - = paginate @merge_requests, theme: "gitlab" + = paginate @merge_requests, theme: "gitlab", total_pages: @total_pages diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index a2e819fb3a7..f3c44c94a5c 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -17,7 +17,7 @@ .issuable-meta = issuable_meta(@merge_request, @project, "Merge request") - .issuable-actions + .issuable-actions.js-issuable-actions .clearfix.issue-btn-group.dropdown %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } Options diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 647e0a772b1..adffd67029a 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -68,9 +68,10 @@ - if git_import_enabled? %button.btn.js-toggle-button.import_git{ type: "button" } = icon('git', text: 'Repo by URL') - .import_gitlab_project.has-tooltip{ data: { container: 'body' } } - = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do - = icon('gitlab', text: 'GitLab export') + - if gitlab_project_import_enabled? + .import_gitlab_project.has-tooltip{ data: { container: 'body' } } + = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do + = icon('gitlab', text: 'GitLab export') .row .col-lg-12 @@ -111,7 +112,7 @@ %span.light (optional) = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250 - .form-group.project-visibility-level-holder + .form-group.visibility-level-setting = f.label :visibility_level, class: 'label-light' do Visibility Level = link_to icon('question-circle'), help_page_path("public_access/public_access"), aria: { label: 'Documentation for Visibility Level' } diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index cb737d129f0..fb07141d2ac 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -26,8 +26,12 @@ ":title" => "buttonText", ":ref" => "'button'" } - = icon('spin spinner', 'v-show' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading') - %div{ 'v-show' => '!loading' }= render 'shared/icons/icon_status_success.svg' + = icon('spin spinner', 'v-if' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading') + %div{ 'v-else' => '' } + %template{ 'v-if' => 'isResolved' } + = render 'shared/icons/icon_status_success_solid.svg' + %template{ 'v-else' => '' } + = render 'shared/icons/icon_resolve_discussion.svg' - if current_user - if note.emoji_awardable? diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml index 2ef1f98ba48..ac8e15a48b2 100644 --- a/app/views/projects/runners/_form.html.haml +++ b/app/views/projects/runners/_form.html.haml @@ -7,6 +7,12 @@ = f.check_box :active %span.light Paused Runners don't accept new jobs .form-group + = label :protected, "Protected", class: 'control-label' + .col-sm-10 + .checkbox + = f.check_box :access_level, {}, 'ref_protected', 'not_protected' + %span.light This runner will only run on pipelines trigged on protected branches + .form-group = label :run_untagged, 'Run untagged jobs', class: 'control-label' .col-sm-10 .checkbox diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml index 49415ba557b..dfab04aa1fb 100644 --- a/app/views/projects/runners/show.html.haml +++ b/app/views/projects/runners/show.html.haml @@ -20,6 +20,9 @@ %td Active %td= @runner.active? ? 'Yes' : 'No' %tr + %td Protected + %td= @runner.ref_protected? ? 'Yes' : 'No' + %tr %td Can run untagged jobs %td= @runner.run_untagged? ? 'Yes' : 'No' %tr diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index 15ba09b10ba..7d24c6a9122 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -23,7 +23,7 @@ = link_to project_settings_ci_cd_path(@project), title: 'Pipelines' do %span Pipelines - - if Gitlab.config.pages.enabled + - if @project.pages_available? = nav_link(controller: :pages) do = link_to project_pages_path(@project), title: 'Pages' do %span diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 8ded7440de3..23a418ad640 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -14,10 +14,10 @@ %ul %li = link_to_label(label, subject: subject, type: :merge_request) do - view merge requests + View merge requests %li = link_to_label(label, subject: subject) do - view open issues + View open issues - if current_user %li.label-subscription - if can_subscribe_to_label_in_different_levels?(label) diff --git a/app/views/shared/_logo.svg b/app/views/shared/_logo.svg index 10e6c49ae9f..0ef9de5fed6 100644 --- a/app/views/shared/_logo.svg +++ b/app/views/shared/_logo.svg @@ -1,4 +1,4 @@ -<svg width="28" height="28" class="tanuki-logo" viewBox="0 0 36 36"> +<svg width="24" height="24" class="tanuki-logo" viewBox="0 0 36 36"> <path class="tanuki-shape tanuki-left-ear" fill="#e24329" d="M2 14l9.38 9v-9l-4-12.28c-.205-.632-1.176-.632-1.38 0z"/> <path class="tanuki-shape tanuki-right-ear" fill="#e24329" d="M34 14l-9.38 9v-9l4-12.28c.205-.632 1.176-.632 1.38 0z"/> <path class="tanuki-shape tanuki-nose" fill="#e24329" d="M18,34.38 3,14 33,14 Z"/> diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml index 73efec88bb1..192d2502aaf 100644 --- a/app/views/shared/_visibility_level.html.haml +++ b/app/views/shared/_visibility_level.html.haml @@ -1,6 +1,6 @@ - with_label = local_assigns.fetch(:with_label, true) -.form-group.project-visibility-level-holder +.form-group.visibility-level-setting - if with_label = f.label :visibility_level, class: 'control-label' do Visibility Level diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml index 182c4eebd50..0ec7677a566 100644 --- a/app/views/shared/_visibility_radios.html.haml +++ b/app/views/shared/_visibility_radios.html.haml @@ -1,15 +1,17 @@ - Gitlab::VisibilityLevel.values.each do |level| - - next if skip_level?(form_model, level) - .radio - - restricted = restricted_visibility_levels.include?(level) + - disallowed = disallowed_visibility_level?(form_model, level) + - restricted = restricted_visibility_levels.include?(level) + - disabled = disallowed || restricted + .radio{ class: [('disabled' if disabled), ('restricted' if restricted)] } = form.label "#{model_method}_#{level}" do - = form.radio_button model_method, level, checked: (selected_level == level), disabled: restricted + = form.radio_button model_method, level, checked: (selected_level == level), disabled: disabled = visibility_level_icon(level) .option-title = visibility_level_label(level) - .option-descr + .option-description = visibility_level_description(level, form_model) -- unless restricted_visibility_levels.empty? - %div - %span.info - Some visibility level settings have been restricted by the administrator. + .option-disabled-reason + - if restricted + = restricted_visibility_level_description(level) + - elsif disallowed + = disallowed_visibility_level_description(level, form_model) diff --git a/app/views/shared/icons/_caret_down.svg b/app/views/shared/icons/_caret_down.svg new file mode 100644 index 00000000000..fd80fd0f651 --- /dev/null +++ b/app/views/shared/icons/_caret_down.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="caret-down" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 10.243l-4.95-4.95a1 1 0 0 0-1.414 1.414l5.657 5.657a.997.997 0 0 0 1.414 0l5.657-5.657a1 1 0 0 0-1.414-1.414L8 10.243z"/></svg> diff --git a/app/views/shared/icons/_icon_arrow_right.svg.erb b/app/views/shared/icons/_icon_arrow_right.svg.erb new file mode 100644 index 00000000000..24d64eb73bd --- /dev/null +++ b/app/views/shared/icons/_icon_arrow_right.svg.erb @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 6H2a2 2 0 1 0 0 4h7v2.586a1 1 0 0 0 1.707.707l4.586-4.586a1 1 0 0 0 0-1.414l-4.586-4.586A1 1 0 0 0 9 3.414V6z"/></svg> diff --git a/app/views/shared/icons/_icon_resolve_discussion.svg b/app/views/shared/icons/_icon_resolve_discussion.svg new file mode 100644 index 00000000000..845562e9320 --- /dev/null +++ b/app/views/shared/icons/_icon_resolve_discussion.svg @@ -0,0 +1 @@ +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg> diff --git a/app/views/shared/icons/_icon_status_success_solid.svg b/app/views/shared/icons/_icon_status_success_solid.svg new file mode 100644 index 00000000000..0aac6d933e1 --- /dev/null +++ b/app/views/shared/icons/_icon_status_success_solid.svg @@ -0,0 +1 @@ +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_mr_bold.svg b/app/views/shared/icons/_mr_bold.svg index 5468545da2e..0f5be6e2bc8 100644 --- a/app/views/shared/icons/_mr_bold.svg +++ b/app/views/shared/icons/_mr_bold.svg @@ -1,2 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg> - +<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg> diff --git a/app/views/shared/icons/_plus_square.svg b/app/views/shared/icons/_plus_square.svg new file mode 100644 index 00000000000..7263d924f1f --- /dev/null +++ b/app/views/shared/icons/_plus_square.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 7V4c0-.552-.448-1-1-1s-1 .448-1 1v3H4c-.552 0-1 .448-1 1s.448 1 1 1h3v3c0 .552.448 1 1 1s1-.448 1-1V9h3c.552 0 1-.448 1-1s-.448-1-1-1H9zM3 0h10c1.657 0 3 1.343 3 3v10c0 1.657-1.343 3-3 3H3c-1.657 0-3-1.343-3-3V3c0-1.657 1.343-3 3-3z"/></svg> diff --git a/app/views/shared/icons/_thumbs_up.svg b/app/views/shared/icons/_thumbs_up.svg new file mode 100644 index 00000000000..7267462418e --- /dev/null +++ b/app/views/shared/icons/_thumbs_up.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.33 5h5.282a2 2 0 0 1 1.963 2.38l-.563 2.905a3 3 0 0 1-.243.732l-1.104 2.286A3 3 0 0 1 10.964 15H7a3 3 0 0 1-3-3V5.7a2 2 0 0 1 .436-1.247l3.11-3.9A.632.632 0 0 1 8.486.5l.138.137a1 1 0 0 1 .28.87L8.33 5zM1 6h2v7H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></svg> diff --git a/app/views/shared/icons/_todo_done.svg b/app/views/shared/icons/_todo_done.svg new file mode 100644 index 00000000000..156dfa11df1 --- /dev/null +++ b/app/views/shared/icons/_todo_done.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.243 7.485l4.95-4.95a1 1 0 1 1 1.414 1.415L8.95 9.607a.997.997 0 0 1-1.414 0l-2.83-2.83a1 1 0 0 1 1.415-1.413l2.123 2.12zM12 11a1 1 0 0 1 2 0v2a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h2a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-2z"/></svg> diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index f22b6c9a6c2..cb706d80f23 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -4,9 +4,9 @@ - if can_update && is_current_user = link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method, - class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" + class: "hidden-xs hidden-sm btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" = link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method, - class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" + class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" - elsif can_update && !is_current_user = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable - elsif issuable.author diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml index daa05990ae9..d8144a39b23 100644 --- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml +++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml @@ -2,7 +2,7 @@ - button_action = issuable.closed? ? 'reopen' : 'close' - display_button_action = button_action.capitalize - button_responsive_class = 'hidden-xs hidden-sm' -- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button" +- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button" - toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" - button_method = issuable_close_reopen_button_method(issuable) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c016aa2abcd..bb02dfa0d3a 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -29,18 +29,6 @@ = render 'shared/issuable/form/metadata', issuable: issuable, form: form -- if issuable.can_move?(current_user) - %hr - .form-group - = label_tag :move_to_project_id, 'Move', class: 'control-label' - .col-sm-10 - .issuable-form-select-holder - = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE } - - %span{ data: { toggle: 'tooltip', placement: 'auto top' }, style: 'cursor: default', - title: 'Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.' } - = icon('question-circle') - = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form = render 'shared/issuable/form/merge_params', issuable: issuable diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f63b9698408..e81789ea7a2 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -93,6 +93,13 @@ %span.dropdown-label-box{ style: 'background: {{color}}' } %span.label-title.js-data-value {{title}} + #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link + %gl-emoji + %span.js-data-value.prepend-left-10 + {{name}} %button.clear-search.hidden{ type: 'button' } = icon('times') .filter-dropdown-container diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index c3f25c9d255..b07bc45512f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -34,7 +34,7 @@ Milestone = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if issuable.milestone = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 } @@ -60,7 +60,7 @@ Due date = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed %span.value-content - if issuable.due_date @@ -95,7 +95,7 @@ Labels = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } - if selected_labels.any? - selected_labels.each do |label| @@ -141,5 +141,22 @@ %cite{ title: project_ref } = project_ref = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") + - if current_user && issuable.can_move?(current_user) + .block.js-sidebar-move-issue-block + .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' } + = custom_icon('icon_arrow_right') + .dropdown.sidebar-move-issue-dropdown.hide-collapsed + %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', + data: { toggle: 'dropdown' } } + Move issue + .dropdown-menu.dropdown-menu-selectable + = dropdown_title('Move issue') + = dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search') + = dropdown_content + = dropdown_loading + = dropdown_footer add_content_class: true do + %button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true } + Move + = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 57392cd7fbb..58782fa5f58 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -13,7 +13,7 @@ Assignee = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' - if !signed_in %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } = sidebar_gutter_toggle_icon diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml index 66091d95a91..9b2b6e572e7 100644 --- a/app/views/shared/issuable/form/_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -11,7 +11,7 @@ Assignee = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if assignees.any? - assignees.each do |assignee| diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml index 18011d528a0..bf8613b0f0d 100644 --- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -9,7 +9,7 @@ Assignee = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if merge_request.assignee = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index 6a85f7d0564..305e2542281 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -5,7 +5,7 @@ .row .col-sm-6 %strong= link_to truncate(milestone.title, length: 100), milestone_path - - if milestone.is_group_milestone? + - if milestone.group_milestone? %span - Group Milestone - else %span - Project Milestone @@ -18,10 +18,10 @@ · = 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.is_group_milestone? + - if milestone.is_a?(GlobalMilestone) || milestone.group_milestone? .row .col-sm-6 - - if milestone.is_legacy_group_milestone? + - if milestone.legacy_group_milestone? .expiration= render('shared/milestone_expired', milestone: milestone) .projects - milestone.milestones.each do |milestone| @@ -31,7 +31,7 @@ - if @group .col-sm-6.milestone-actions - if can?(current_user, :admin_milestones, @group) - - if milestone.is_group_milestone? + - if milestone.group_milestone? = link_to edit_group_milestone_path(@group, milestone), class: "btn btn-xs btn-grouped" do Edit \ diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 40379f48393..f03e0ab154c 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -21,7 +21,7 @@ .title Start date - if @project && can?(current_user, :admin_milestone, @project) - = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right' + = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value %span.value-content - if milestone.start_date @@ -51,7 +51,7 @@ .title.hide-collapsed Due date - if @project && can?(current_user, :admin_milestone, @project) - = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right' + = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed %span.value-content - if milestone.due_date @@ -88,7 +88,7 @@ .block.merge-requests .sidebar-collapsed-icon %strong - = icon('exclamation', 'aria-hidden': 'true') + = custom_icon('mr_bold') %span= milestone.merge_requests.count .title.hide-collapsed Merge requests diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 3014300fbe7..fd0760d83a5 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -22,7 +22,7 @@ - if group .pull-right - if can?(current_user, :admin_milestones, group) - - if milestone.is_group_milestone? + - if milestone.group_milestone? = link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do Edit - if milestone.active? @@ -33,7 +33,7 @@ .detail-page-description.milestone-detail %h2.title = markdown_field(milestone, :title) - - if @milestone.is_group_milestone? && @milestone.description.present? + - if @milestone.group_milestone? && @milestone.description.present? %div .description .wiki @@ -44,7 +44,7 @@ - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' %span All issues for this milestone are closed. #{close_msg} -- if @milestone.is_legacy_group_milestone? || @milestone.is_dashboard_milestone? +- if @milestone.legacy_group_milestone? || @milestone.dashboard_milestone? .table-holder %table.table %thead @@ -67,7 +67,7 @@ Open %td = ms.expires_at -- elsif @milestone.is_group_milestone? +- elsif @milestone.group_milestone? %br View = link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title) diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index eae04c9bbb8..e3e86709b8f 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -17,9 +17,9 @@ - elsif !current_user .disabled-comment.text-center.prepend-top-default Please - = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link' or - = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link' to comment %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb index f34dff2d656..9b5ff17aafa 100644 --- a/app/workers/create_gpg_signature_worker.rb +++ b/app/workers/create_gpg_signature_worker.rb @@ -6,7 +6,11 @@ class CreateGpgSignatureWorker project = Project.find_by(id: project_id) return unless project + commit = project.commit(commit_sha) + + return unless commit + # This calculates and caches the signature in the database - Gitlab::Gpg::Commit.new(project, commit_sha).signature + Gitlab::Gpg::Commit.new(commit).signature end end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index c3b58df92c1..48e2da338f6 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -7,8 +7,6 @@ class MergeWorker current_user = User.find(current_user_id) merge_request = MergeRequest.find(merge_request_id) - merge_request.update_column(:merge_jid, jid) - MergeRequests::MergeService.new(merge_request.target_project, current_user, params) .execute(merge_request) end diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 8b0cfcc8af8..269776a1f62 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -53,7 +53,7 @@ class StuckCiJobsWorker def drop_build(type, build, status, timeout) Rails.logger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout})" Gitlab::OptimisticLocking.retry_lock(build, 3) do |b| - b.drop + b.drop(:stuck_or_timeout_failure) end end end |