diff options
author | Regis <boudinot.regis@yahoo.com> | 2017-03-21 09:08:28 -0600 |
---|---|---|
committer | Regis <boudinot.regis@yahoo.com> | 2017-03-21 09:08:28 -0600 |
commit | 0b75b821c6cfd173291fcfd88c41da9922d082dd (patch) | |
tree | 41b578d299bd77423aa3591955a4cb5ca07ab025 /app | |
parent | 6342da7bb6cbba1b1e026fc62a1da42b811b25f4 (diff) | |
parent | a08c707c928092426e2334423e71c6b841309ddf (diff) | |
download | gitlab-ce-0b75b821c6cfd173291fcfd88c41da9922d082dd.tar.gz |
update to current master and fix conflictsissue-title-vue
Diffstat (limited to 'app')
238 files changed, 2998 insertions, 2802 deletions
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 8a077f0081a..9349918f7a0 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -3,6 +3,7 @@ import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; import { glEmojiTag } from './behaviors/gl_emoji'; +import isEmojiNameValid from './behaviors/gl_emoji/is_emoji_name_valid'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const requestAnimationFrame = window.requestAnimationFrame || @@ -454,14 +455,21 @@ AwardsHandler.prototype.normalizeEmojiName = function normalizeEmojiName(emoji) AwardsHandler .prototype .addEmojiToFrequentlyUsedList = function addEmojiToFrequentlyUsedList(emoji) { - const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis(); - frequentlyUsedEmojis.push(emoji); - Cookies.set('frequently_used_emojis', frequentlyUsedEmojis.join(','), { expires: 365 }); + if (isEmojiNameValid(emoji)) { + this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji)); + Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 }); + } }; AwardsHandler.prototype.getFrequentlyUsedEmojis = function getFrequentlyUsedEmojis() { - const frequentlyUsedEmojis = (Cookies.get('frequently_used_emojis') || '').split(','); - return _.compact(_.uniq(frequentlyUsedEmojis)); + return this.frequentlyUsedEmojis || (() => { + const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(',')); + this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter( + inputName => isEmojiNameValid(inputName), + ); + + return this.frequentlyUsedEmojis; + })(); }; AwardsHandler.prototype.setupSearch = function setupSearch() { diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 59741cc9b1a..19a607309e4 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -13,9 +13,14 @@ function emojiImageTag(name, src) { } function assembleFallbackImageSrc(inputName) { - const name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ? + let name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ? emojiAliases[inputName] : inputName; - const emojiInfo = emojiMap[name]; + let emojiInfo = emojiMap[name]; + // Fallback to question mark for unknown emojis + if (!emojiInfo) { + name = 'grey_question'; + emojiInfo = emojiMap[name]; + } const fallbackImageSrc = `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${emojiInfo.digest}.png`; return fallbackImageSrc; @@ -26,9 +31,15 @@ const glEmojiTagDefaults = { }; function glEmojiTag(inputName, options) { const opts = Object.assign({}, glEmojiTagDefaults, options); - const name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ? + let name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ? emojiAliases[inputName] : inputName; - const emojiInfo = emojiMap[name]; + let emojiInfo = emojiMap[name]; + // Fallback to question mark for unknown emojis + if (!emojiInfo) { + name = 'grey_question'; + emojiInfo = emojiMap[name]; + } + const fallbackImageSrc = assembleFallbackImageSrc(name); const fallbackSpriteClass = `emoji-${name}`; diff --git a/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js new file mode 100644 index 00000000000..be4aeb32c46 --- /dev/null +++ b/app/assets/javascripts/behaviors/gl_emoji/is_emoji_name_valid.js @@ -0,0 +1,11 @@ +import emojiMap from 'emojis/digests.json'; +import emojiAliases from 'emojis/aliases.json'; + +function isEmojiNameValid(inputName) { + const name = Object.prototype.hasOwnProperty.call(emojiAliases, inputName) ? + emojiAliases[inputName] : inputName; + + return name && emojiMap[name]; +} + +export default isEmojiNameValid; diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 0726c6c9636..92f3bb3ff52 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -21,8 +21,13 @@ // %a.js-toggle-button // %div.js-toggle-content // - $('body').on('click', '.js-toggle-button', function() { + $('body').on('click', '.js-toggle-button', function(e) { toggleContainer($(this).closest('.js-toggle-container')); + + const targetTag = e.target.tagName.toLowerCase(); + if (targetTag === 'a' || targetTag === 'button') { + e.preventDefault(); + } }); // If we're accessing a permalink, ensure it is not inside a diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 30d3be453be..67c0c419713 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -2,7 +2,8 @@ /* global Vue */ /* global Sortable */ -require('./board_blank_state'); +import boardBlankState from './board_blank_state'; + require('./board_delete'); require('./board_list'); @@ -17,7 +18,7 @@ require('./board_list'); components: { 'board-list': gl.issueBoards.BoardList, 'board-delete': gl.issueBoards.BoardDelete, - 'board-blank-state': gl.issueBoards.BoardBlankState + boardBlankState, }, props: { list: Object, diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js index d76314c1892..52893d4642b 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.js +++ b/app/assets/javascripts/boards/components/board_blank_state.js @@ -1,53 +1,84 @@ -/* eslint-disable space-before-function-paren, comma-dangle */ -/* global Vue */ /* global ListLabel */ +/* global Cookies */ +const Store = gl.issueBoards.BoardsStore; -(() => { - const Store = gl.issueBoards.BoardsStore; +export default { + template: ` + <div class="board-blank-state"> + <p> + Add the following default lists to your Issue Board with one click: + </p> + <ul class="board-blank-state-list"> + <li v-for="label in predefinedLabels"> + <span + class="label-color" + :style="{ backgroundColor: label.color }"> + </span> + {{ label.title }} + </li> + </ul> + <p> + Starting out with the default set of lists will get you right on the way to making the most of your board. + </p> + <button + class="btn btn-create btn-inverted btn-block" + type="button" + @click.stop="addDefaultLists"> + Add default lists + </button> + <button + class="btn btn-default btn-block" + type="button" + @click.stop="clearBlankState"> + Nevermind, I'll use my own + </button> + </div> + `, + data() { + return { + predefinedLabels: [ + new ListLabel({ title: 'To Do', color: '#F0AD4E' }), + new ListLabel({ title: 'Doing', color: '#5CB85C' }), + ], + }; + }, + methods: { + addDefaultLists() { + this.clearBlankState(); - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.BoardBlankState = Vue.extend({ - data () { - return { - predefinedLabels: [ - new ListLabel({ title: 'To Do', color: '#F0AD4E' }), - new ListLabel({ title: 'Doing', color: '#5CB85C' }) - ] - }; - }, - methods: { - addDefaultLists () { - this.clearBlankState(); - - this.predefinedLabels.forEach((label, i) => { - Store.addList({ + this.predefinedLabels.forEach((label, i) => { + Store.addList({ + title: label.title, + position: i, + list_type: 'label', + label: { title: label.title, - position: i, - list_type: 'label', - label: { - title: label.title, - color: label.color - } - }); + color: label.color, + }, }); + }); - Store.state.lists = _.sortBy(Store.state.lists, 'position'); + Store.state.lists = _.sortBy(Store.state.lists, 'position'); - // Save the labels - gl.boardService.generateDefaultLists() - .then((resp) => { - resp.json().forEach((listObj) => { - const list = Store.findList('title', listObj.title); + // Save the labels + gl.boardService.generateDefaultLists() + .then((resp) => { + resp.json().forEach((listObj) => { + const list = Store.findList('title', listObj.title); - list.id = listObj.id; - list.label.id = listObj.label.id; - list.getIssues(); - }); + list.id = listObj.id; + list.label.id = listObj.label.id; + list.getIssues(); }); - }, - clearBlankState: Store.removeBlankState.bind(Store) - } - }); -})(); + }) + .catch(() => { + Store.removeList(undefined, 'label'); + Cookies.remove('issue_board_welcome_hidden', { + path: '', + }); + Store.addBlankState(); + }); + }, + clearBlankState: Store.removeBlankState.bind(Store), + }, +}; diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js index 9538f5b69e9..e6973c3fd59 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.js +++ b/app/assets/javascripts/boards/components/modal/empty_state.js @@ -30,7 +30,7 @@ if (this.activeTab === 'selected') { obj.title = 'You haven\'t selected any issues yet'; obj.content = ` - Go back to <strong>All issues</strong> and select some issues + Go back to <strong>Open issues</strong> and select some issues to add to your board. `; } @@ -59,7 +59,7 @@ class="btn btn-default" @click="changeTab('all')" v-if="activeTab === 'selected'"> - All issues + Open issues </button> </div> </div> diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js index 6de06811d94..bd394a2318c 100644 --- a/app/assets/javascripts/boards/components/modal/filters.js +++ b/app/assets/javascripts/boards/components/modal/filters.js @@ -1,49 +1,24 @@ -/* global Vue */ -const userFilter = require('./filters/user'); -const milestoneFilter = require('./filters/milestone'); -const labelFilter = require('./filters/label'); +import FilteredSearchBoards from '../../filtered_search_boards'; +import FilteredSearchContainer from '../../../filtered_search/container'; -module.exports = Vue.extend({ +export default { name: 'modal-filters', props: { - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, + store: { + type: Object, required: true, }, }, - destroyed() { - gl.issueBoards.ModalStore.setDefaultFilter(); + mounted() { + FilteredSearchContainer.container = this.$el; + + this.filteredSearch = new FilteredSearchBoards(this.store); + this.filteredSearch.removeTokens(); }, - components: { - userFilter, - milestoneFilter, - labelFilter, + beforeDestroy() { + this.filteredSearch.cleanup(); + FilteredSearchContainer.container = document; + this.store.path = ''; }, - template: ` - <div class="modal-filters"> - <user-filter - dropdown-class-name="dropdown-menu-author" - toggle-class-name="js-user-search js-author-search" - toggle-label="Author" - field-name="author_id" - :project-id="projectId"></user-filter> - <user-filter - dropdown-class-name="dropdown-menu-author" - toggle-class-name="js-assignee-search" - toggle-label="Assignee" - field-name="assignee_id" - :null-user="true" - :project-id="projectId"></user-filter> - <milestone-filter :milestone-path="milestonePath"></milestone-filter> - <label-filter :label-path="labelPath"></label-filter> - </div> - `, -}); + template: '#js-board-modal-filter', +}; diff --git a/app/assets/javascripts/boards/components/modal/filters/label.js b/app/assets/javascripts/boards/components/modal/filters/label.js deleted file mode 100644 index 4fc8f72a145..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters/label.js +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global LabelsSelect */ -module.exports = Vue.extend({ - name: 'filter-label', - props: { - labelPath: { - type: String, - required: true, - }, - }, - mounted() { - new LabelsSelect(this.$refs.dropdown); - }, - template: ` - <div class="dropdown"> - <button - class="dropdown-menu-toggle js-label-select js-multiselect js-extra-options" - type="button" - data-toggle="dropdown" - data-show-any="true" - data-show-no="true" - :data-labels="labelPath" - ref="dropdown"> - <span class="dropdown-toggle-text"> - Label - </span> - <i class="fa fa-chevron-down"></i> - </button> - <div class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable"> - <div class="dropdown-title"> - Filter by label - <button - class="dropdown-title-button dropdown-menu-close" - aria-label="Close" - type="button"> - <i class="fa fa-times dropdown-menu-close-icon"></i> - </button> - </div> - <div class="dropdown-input"> - <input - type="search" - class="dropdown-input-field" - placeholder="Search" - autocomplete="off" /> - <i class="fa fa-search dropdown-input-search"></i> - <i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i> - </div> - <div class="dropdown-content"></div> - <div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/filters/milestone.js b/app/assets/javascripts/boards/components/modal/filters/milestone.js deleted file mode 100644 index d555599d300..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters/milestone.js +++ /dev/null @@ -1,55 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global MilestoneSelect */ -module.exports = Vue.extend({ - name: 'filter-milestone', - props: { - milestonePath: { - type: String, - required: true, - }, - }, - mounted() { - new MilestoneSelect(null, this.$refs.dropdown); - }, - template: ` - <div class="dropdown"> - <button - class="dropdown-menu-toggle js-milestone-select" - type="button" - data-toggle="dropdown" - data-show-any="true" - data-show-upcoming="true" - data-field-name="milestone_title" - :data-milestones="milestonePath" - ref="dropdown"> - <span class="dropdown-toggle-text"> - Milestone - </span> - <i class="fa fa-chevron-down"></i> - </button> - <div class="dropdown-menu dropdown-select dropdown-menu-selectable dropdown-menu-milestone"> - <div class="dropdown-title"> - <span>Filter by milestone</span> - <button - class="dropdown-title-button dropdown-menu-close" - aria-label="Close" - type="button"> - <i class="fa fa-times dropdown-menu-close-icon"></i> - </button> - </div> - <div class="dropdown-input"> - <input - type="search" - class="dropdown-input-field" - placeholder="Search milestones" - autocomplete="off" /> - <i class="fa fa-search dropdown-input-search"></i> - <i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i> - </div> - <div class="dropdown-content"></div> - <div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/filters/user.js b/app/assets/javascripts/boards/components/modal/filters/user.js deleted file mode 100644 index 8523028c29c..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters/user.js +++ /dev/null @@ -1,96 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global UsersSelect */ -module.exports = Vue.extend({ - name: 'filter-user', - props: { - toggleClassName: { - type: String, - required: true, - }, - dropdownClassName: { - type: String, - required: false, - default: '', - }, - toggleLabel: { - type: String, - required: true, - }, - fieldName: { - type: String, - required: true, - }, - nullUser: { - type: Boolean, - required: false, - default: false, - }, - projectId: { - type: Number, - required: true, - }, - }, - mounted() { - new UsersSelect(null, this.$refs.dropdown); - }, - computed: { - currentUsername() { - return gon.current_username; - }, - dropdownTitle() { - return `Filter by ${this.toggleLabel.toLowerCase()}`; - }, - inputPlaceholder() { - return `Search ${this.toggleLabel.toLowerCase()}`; - }, - }, - template: ` - <div class="dropdown"> - <button - class="dropdown-menu-toggle js-user-search" - :class="toggleClassName" - type="button" - data-toggle="dropdown" - data-current-user="true" - :data-any-user="'Any ' + toggleLabel" - :data-null-user="nullUser" - :data-field-name="fieldName" - :data-project-id="projectId" - :data-first-user="currentUsername" - ref="dropdown"> - <span class="dropdown-toggle-text"> - {{ toggleLabel }} - </span> - <i class="fa fa-chevron-down"></i> - </button> - <div - class="dropdown-menu dropdown-select dropdown-menu-user dropdown-menu-selectable" - :class="dropdownClassName"> - <div class="dropdown-title"> - {{ dropdownTitle }} - <button - class="dropdown-title-button dropdown-menu-close" - aria-label="Close" - type="button"> - <i class="fa fa-times dropdown-menu-close-icon"></i> - </button> - </div> - <div class="dropdown-input"> - <input - type="search" - class="dropdown-input-field" - autocomplete="off" - :placeholder="inputPlaceholder" /> - <i class="fa fa-search dropdown-input-search"></i> - <i - role="button" - class="fa fa-times dropdown-input-clear js-dropdown-input-clear"> - </i> - </div> - <div class="dropdown-content"></div> - <div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js index 70c088f9054..116e29cd177 100644 --- a/app/assets/javascripts/boards/components/modal/header.js +++ b/app/assets/javascripts/boards/components/modal/header.js @@ -1,6 +1,7 @@ -/* global Vue */ +import Vue from 'vue'; +import modalFilters from './filters'; + require('./tabs'); -const modalFilters = require('./filters'); (() => { const ModalStore = gl.issueBoards.ModalStore; @@ -66,16 +67,7 @@ const modalFilters = require('./filters'); <div class="add-issues-search append-bottom-10" v-if="showSearch"> - <modal-filters - :project-id="projectId" - :milestone-path="milestonePath" - :label-path="labelPath"> - </modal-filters> - <input - placeholder="Search issues..." - class="form-control" - type="search" - v-model="searchTerm" /> + <modal-filters :store="filter" /> <button type="button" class="btn btn-success btn-inverted prepend-left-10" diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index f290cd13763..4240c97617d 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -1,5 +1,6 @@ /* global Vue */ /* global ListIssue */ +import queryData from '../../utils/query_data'; require('./header'); require('./list'); @@ -47,9 +48,6 @@ require('./empty_state'); page() { this.loadIssues(); }, - searchTerm() { - this.searchOperation(); - }, showAddIssuesModal() { if (this.showAddIssuesModal && !this.issues.length) { this.loading = true; @@ -66,25 +64,20 @@ require('./empty_state'); }, filter: { handler() { + this.page = 1; this.loadIssues(true); }, deep: true, }, }, methods: { - searchOperation: _.debounce(function searchOperationDebounce() { - this.loadIssues(true); - }, 500), loadIssues(clearIssues = false) { if (!this.showAddIssuesModal) return false; - const queryData = Object.assign({}, this.filter, { - search: this.searchTerm, + return gl.boardService.getBacklog(queryData(this.filter.path, { page: this.page, per: this.perPage, - }); - - return gl.boardService.getBacklog(queryData).then((res) => { + })).then((res) => { const data = res.json(); if (clearIssues) { @@ -123,6 +116,9 @@ require('./empty_state'); return this.activeTab === 'selected' && this.selectedIssues.length === 0; }, }, + created() { + this.page = 1; + }, components: { 'modal-header': gl.issueBoards.ModalHeader, 'modal-list': gl.issueBoards.ModalList, diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js index e8cb43f3503..1cd6ca0ee88 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.js +++ b/app/assets/javascripts/boards/components/modal/tabs.js @@ -23,7 +23,7 @@ href="#" role="button" @click.prevent="changeTab('all')"> - All issues + Open issues <span class="badge"> {{ issuesCount }} </span> diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 47448b02bdd..101732309ea 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,3 +1,6 @@ +/* eslint-disable class-methods-use-this */ +import FilteredSearchContainer from '../filtered_search/container'; + export default class FilteredSearchBoards extends gl.FilteredSearchManager { constructor(store, updateUrl = false) { super('boards'); @@ -18,13 +21,17 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { } } - updateTokens() { - const tokens = document.querySelectorAll('.js-visual-token'); + removeTokens() { + const tokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token'); // Remove all the tokens as they will be replaced by the search manager [].forEach.call(tokens, (el) => { el.parentNode.removeChild(el); }); + } + + updateTokens() { + this.removeTokens(); this.loadSearchParamsFromURL(); diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 3251ca76b26..f18ad2a0fac 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,6 +1,7 @@ /* eslint-disable space-before-function-paren, no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign, max-len, no-unused-vars */ /* global ListIssue */ /* global ListLabel */ +import queryData from '../utils/query_data'; class List { constructor (obj) { @@ -64,25 +65,7 @@ class List { } getIssues (emptyIssues = true) { - const data = gl.issueBoards.BoardsStore.filter.path.split('&').reduce((data, filterParam) => { - if (filterParam === '') return data; - const paramSplit = filterParam.split('='); - const paramKeyNormalized = paramSplit[0].replace('[]', ''); - const isArray = paramSplit[0].indexOf('[]'); - const value = decodeURIComponent(paramSplit[1]).replace(/\+/g, ' '); - - if (isArray !== -1) { - if (!data[paramKeyNormalized]) { - data[paramKeyNormalized] = []; - } - - data[paramKeyNormalized].push(value); - } else { - data[paramKeyNormalized] = value; - } - - return data; - }, { page: this.page }); + const data = queryData(gl.issueBoards.BoardsStore.filter.path, { page: this.page }); if (this.label && data.label_name) { data.label_name = data.label_name.filter(label => label !== this.label.title); diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js index 15fc6c79e8d..7ee266a831f 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -17,17 +17,9 @@ loadingNewPage: false, page: 1, perPage: 50, - }; - - this.setDefaultFilter(); - } - - setDefaultFilter() { - this.store.filter = { - author_id: '', - assignee_id: '', - milestone_title: '', - label_name: [], + filter: { + path: '', + }, }; } diff --git a/app/assets/javascripts/boards/utils/query_data.js b/app/assets/javascripts/boards/utils/query_data.js new file mode 100644 index 00000000000..2cd3c146f11 --- /dev/null +++ b/app/assets/javascripts/boards/utils/query_data.js @@ -0,0 +1,21 @@ +export default (path, extraData) => path.split('&').reduce((dataParam, filterParam) => { + if (filterParam === '') return dataParam; + + const data = dataParam; + const paramSplit = filterParam.split('='); + const paramKeyNormalized = paramSplit[0].replace('[]', ''); + const isArray = paramSplit[0].indexOf('[]'); + const value = decodeURIComponent(paramSplit[1]).replace(/\+/g, ' '); + + if (isArray !== -1) { + if (!data[paramKeyNormalized]) { + data[paramKeyNormalized] = []; + } + + data[paramKeyNormalized].push(value); + } else { + data[paramKeyNormalized] = value; + } + + return data; +}, extraData); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index b5a988df897..a9f2d462c31 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -1,8 +1,9 @@ -/* eslint-disable no-new, no-param-reassign */ -/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ +/* eslint-disable no-param-reassign */ +import CommitPipelinesTable from './pipelines_table'; window.Vue = require('vue'); -require('./pipelines_table'); +window.Vue.use(require('vue-resource')); + /** * Commits View > Pipelines Tab > Pipelines Table. * Merge Request View > Pipelines Tab > Pipelines Table. @@ -21,7 +22,7 @@ $(() => { } const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); - gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView(); + gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable(); if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) { gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_service.js b/app/assets/javascripts/commit/pipelines/pipelines_service.js deleted file mode 100644 index 8ae98f9bf97..00000000000 --- a/app/assets/javascripts/commit/pipelines/pipelines_service.js +++ /dev/null @@ -1,44 +0,0 @@ -/* globals Vue */ -/* eslint-disable no-unused-vars, no-param-reassign */ - -/** - * Pipelines service. - * - * Used to fetch the data used to render the pipelines table. - * Uses Vue.Resource - */ -class PipelinesService { - - /** - * FIXME: The url provided to request the pipelines in the new merge request - * page already has `.json`. - * This should be fixed when the endpoint is improved. - * - * @param {String} root - */ - constructor(root) { - let endpoint; - - if (root.indexOf('.json') === -1) { - endpoint = `${root}.json`; - } else { - endpoint = root; - } - this.pipelines = Vue.resource(endpoint); - } - - /** - * Given the root param provided when the class is initialized, will - * make a GET request. - * - * @return {Promise} - */ - all() { - return this.pipelines.get(); - } -} - -window.gl = window.gl || {}; -gl.commits = gl.commits || {}; -gl.commits.pipelines = gl.commits.pipelines || {}; -gl.commits.pipelines.PipelinesService = PipelinesService; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index 631ed34851c..832c4b1bd2a 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -1,13 +1,12 @@ -/* eslint-disable no-new, no-param-reassign */ -/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ - -window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); -require('../../lib/utils/common_utils'); -require('../../vue_shared/vue_resource_interceptor'); -require('../../vue_shared/components/pipelines_table'); -require('./pipelines_service'); -const PipelineStore = require('./pipelines_store'); +/* eslint-disable no-new*/ +/* global Flash */ +import Vue from 'vue'; +import PipelinesTableComponent from '../../vue_shared/components/pipelines_table'; +import PipelinesService from '../../vue_pipelines_index/services/pipelines_service'; +import PipelineStore from '../../vue_pipelines_index/stores/pipelines_store'; +import eventHub from '../../vue_pipelines_index/event_hub'; +import '../../lib/utils/common_utils'; +import '../../vue_shared/vue_resource_interceptor'; /** * @@ -20,48 +19,59 @@ const PipelineStore = require('./pipelines_store'); * as soon as we have Webpack and can load them directly into JS files. */ -(() => { - window.gl = window.gl || {}; - gl.commits = gl.commits || {}; - gl.commits.pipelines = gl.commits.pipelines || {}; +export default Vue.component('pipelines-table', { + components: { + 'pipelines-table-component': PipelinesTableComponent, + }, - gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', { + /** + * Accesses the DOM to provide the needed data. + * Returns the necessary props to render `pipelines-table-component` component. + * + * @return {Object} + */ + data() { + const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset; + const store = new PipelineStore(); - components: { - 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, - }, + return { + endpoint: pipelinesTableData.endpoint, + store, + state: store.state, + isLoading: false, + }; + }, - /** - * Accesses the DOM to provide the needed data. - * Returns the necessary props to render `pipelines-table-component` component. - * - * @return {Object} - */ - data() { - const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset; - const store = new PipelineStore(); + /** + * When the component is about to be mounted, tell the service to fetch the data + * + * A request to fetch the pipelines will be made. + * In case of a successfull response we will store the data in the provided + * store, in case of a failed response we need to warn the user. + * + */ + beforeMount() { + this.service = new PipelinesService(this.endpoint); - return { - endpoint: pipelinesTableData.endpoint, - store, - state: store.state, - isLoading: false, - }; - }, + this.fetchPipelines(); + + eventHub.$on('refreshPipelines', this.fetchPipelines); + }, + + beforeUpdate() { + if (this.state.pipelines.length && this.$children) { + this.store.startTimeAgoLoops.call(this, Vue); + } + }, - /** - * When the component is about to be mounted, tell the service to fetch the data - * - * A request to fetch the pipelines will be made. - * In case of a successfull response we will store the data in the provided - * store, in case of a failed response we need to warn the user. - * - */ - beforeMount() { - const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint); + beforeDestroyed() { + eventHub.$off('refreshPipelines'); + }, + methods: { + fetchPipelines() { this.isLoading = true; - return pipelinesService.all() + return this.service.getPipelines() .then(response => response.json()) .then((json) => { // depending of the endpoint the response can either bring a `pipelines` key or not. @@ -71,34 +81,30 @@ const PipelineStore = require('./pipelines_store'); }) .catch(() => { this.isLoading = false; - new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert'); + new Flash('An error occurred while fetching the pipelines, please reload the page again.'); }); }, + }, - beforeUpdate() { - if (this.state.pipelines.length && this.$children) { - PipelineStore.startTimeAgoLoops.call(this, Vue); - } - }, - - template: ` - <div class="pipelines"> - <div class="realtime-loading" v-if="isLoading"> - <i class="fa fa-spinner fa-spin"></i> - </div> + template: ` + <div class="pipelines"> + <div class="realtime-loading" v-if="isLoading"> + <i class="fa fa-spinner fa-spin"></i> + </div> - <div class="blank-state blank-state-no-icon" - v-if="!isLoading && state.pipelines.length === 0"> - <h2 class="blank-state-title js-blank-state-title"> - No pipelines to show - </h2> - </div> + <div class="blank-state blank-state-no-icon" + v-if="!isLoading && state.pipelines.length === 0"> + <h2 class="blank-state-title js-blank-state-title"> + No pipelines to show + </h2> + </div> - <div class="table-holder pipelines" - v-if="!isLoading && state.pipelines.length > 0"> - <pipelines-table-component :pipelines="state.pipelines"/> - </div> + <div class="table-holder pipelines" + v-if="!isLoading && state.pipelines.length > 0"> + <pipelines-table-component + :pipelines="state.pipelines" + :service="service" /> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index d91bfb1ccbd..72c0d98d47c 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -18,7 +18,8 @@ window.CompareAutocomplete = (function() { return $.ajax({ url: $dropdown.data('refs-url'), data: { - ref: $dropdown.data('ref') + ref: $dropdown.data('ref'), + search: term, } }).done(function(refs) { return callback(refs); @@ -26,7 +27,7 @@ window.CompareAutocomplete = (function() { }, selectable: true, filterable: true, - filterByText: true, + filterRemote: true, fieldName: $dropdown.data('field-name'), filterInput: 'input[type="search"]', renderRow: function(ref) { diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js index 0fb7bde1fd6..570799c030e 100644 --- a/app/assets/javascripts/copy_as_gfm.js +++ b/app/assets/javascripts/copy_as_gfm.js @@ -118,10 +118,10 @@ const gfmRules = { }, SyntaxHighlightFilter: { 'pre.code.highlight'(el, t) { - const text = t.trim(); + const text = t.trimRight(); let lang = el.getAttribute('lang'); - if (lang === 'plaintext') { + if (!lang || lang === 'plaintext') { lang = ''; } @@ -157,7 +157,7 @@ const gfmRules = { const backticks = Array(backtickCount + 1).join('`'); const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - return backticks + spaceOrNoSpace + text + spaceOrNoSpace + backticks; + return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; }, 'blockquote'(el, text) { return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); @@ -273,28 +273,29 @@ const gfmRules = { class CopyAsGFM { constructor() { - $(document).on('copy', '.md, .wiki', this.handleCopy); - $(document).on('paste', '.js-gfm-input', this.handlePaste); + $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); + $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); + $(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this)); } - handleCopy(e) { + copyAsGFM(e, transformer) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; const documentFragment = window.gl.utils.getSelectedFragment(); if (!documentFragment) return; - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return; + const el = transformer(documentFragment.cloneNode(true)); + if (!el) return; e.preventDefault(); - clipboardData.setData('text/plain', documentFragment.textContent); + e.stopPropagation(); - const gfm = CopyAsGFM.nodeToGFM(documentFragment); - clipboardData.setData('text/x-gfm', gfm); + clipboardData.setData('text/plain', el.textContent); + clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el)); } - handlePaste(e) { + pasteGFM(e) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; @@ -306,7 +307,47 @@ class CopyAsGFM { window.gl.utils.insertText(e.target, gfm); } + static transformGFMSelection(documentFragment) { + // If the documentFragment contains more than just Markdown, don't copy as GFM. + if (documentFragment.querySelector('.md, .wiki')) return null; + + return documentFragment; + } + + static transformCodeSelection(documentFragment) { + const lineEls = documentFragment.querySelectorAll('.line'); + + let codeEl; + if (lineEls.length > 1) { + codeEl = document.createElement('pre'); + codeEl.className = 'code highlight'; + + const lang = lineEls[0].getAttribute('lang'); + if (lang) { + codeEl.setAttribute('lang', lang); + } + } else { + codeEl = document.createElement('code'); + } + + if (lineEls.length > 0) { + for (let i = 0; i < lineEls.length; i += 1) { + const lineEl = lineEls[i]; + codeEl.appendChild(lineEl); + codeEl.appendChild(document.createTextNode('\n')); + } + } else { + codeEl.appendChild(documentFragment); + } + + return codeEl; + } + static nodeToGFM(node) { + if (node.nodeType === Node.COMMENT_NODE) { + return ''; + } + if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } 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 788daa96b3d..dd7081aefb7 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -25,6 +25,7 @@ import collapseIcon from '../icons/collapse_icon.svg'; role="button" data-container="body" data-placement="top" + data-html="true" :data-line-type="lineType" :title="note.authorName + ': ' + note.noteTruncated" :src="note.authorAvatar" diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 6d8174e199e..3557f6f617e 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,4 +1,3 @@ -import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make this a bundle /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ /* global UsernameValidator */ /* global ActiveTabMemoizer */ @@ -37,6 +36,7 @@ import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; +import GroupName from './group_name'; import GroupsList from './groups_list'; import ProjectsList from './projects_list'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; @@ -200,10 +200,13 @@ const UserCallout = require('./user_callout'); new gl.Diff(); new ZenMode(); shortcut_handler = new ShortcutsNavigation(); + new MiniPipelineGraph({ + container: '.js-commit-pipeline-graph', + }).bindEvents(); break; case 'projects:commit:pipelines': new MiniPipelineGraph({ - container: '.js-pipeline-table', + container: '.js-commit-pipeline-graph', }).bindEvents(); break; case 'projects:commits:show': @@ -325,8 +328,6 @@ const UserCallout = require('./user_callout'); case 'ci:lints:show': new gl.CILintEditor(); break; - case 'projects:environments:metrics': - new PrometheusGraph(); case 'users:show': new UserCallout(); break; @@ -368,6 +369,9 @@ const UserCallout = require('./user_callout'); shortcut_handler = new ShortcutsDashboardNavigation(); new UserCallout(); break; + case 'groups': + new GroupName(); + break; case 'profiles': new NotificationsForm(); new NotificationsDropdown(); @@ -375,6 +379,7 @@ const UserCallout = require('./user_callout'); case 'projects': new Project(); new ProjectAvatar(); + new GroupName(); switch (path[1]) { case 'compare': new CompareAutocomplete(); diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index fdbb4644971..db10b383913 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -132,7 +132,7 @@ class DueDateSelect { const selectedDateValue = this.datePayload[this.abilityName].due_date; const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value'; - this.$loading.fadeIn(); + this.$loading.removeClass('hidden').fadeIn(); if (isDropdown) { this.$dropdown.trigger('loading.gl.dropdown'); diff --git a/app/assets/javascripts/environments/components/environment.js b/app/assets/javascripts/environments/components/environment.js index 0923ce6b550..51aab8460f6 100644 --- a/app/assets/javascripts/environments/components/environment.js +++ b/app/assets/javascripts/environments/components/environment.js @@ -1,21 +1,18 @@ -/* eslint-disable no-param-reassign, no-new */ +/* eslint-disable no-new */ /* global Flash */ +import Vue from 'vue'; import EnvironmentsService from '../services/environments_service'; import EnvironmentTable from './environments_table'; import EnvironmentsStore from '../stores/environments_store'; +import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import '../../lib/utils/common_utils'; import eventHub from '../event_hub'; -const Vue = window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); -require('../../vue_shared/components/table_pagination'); -require('../../lib/utils/common_utils'); -require('../../vue_shared/vue_resource_interceptor'); - export default Vue.component('environment-component', { components: { 'environment-table': EnvironmentTable, - 'table-pagination': gl.VueGlPagination, + 'table-pagination': TablePaginationComponent, }, data() { @@ -59,7 +56,6 @@ export default Vue.component('environment-component', { canCreateEnvironmentParsed() { return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment); }, - }, /** diff --git a/app/assets/javascripts/environments/components/environment_external_url.js b/app/assets/javascripts/environments/components/environment_external_url.js index a554998f52c..b4f9eb357fd 100644 --- a/app/assets/javascripts/environments/components/environment_external_url.js +++ b/app/assets/javascripts/environments/components/environment_external_url.js @@ -14,6 +14,7 @@ export default { class="btn external_url" :href="externalUrl" target="_blank" + rel="noopener noreferrer" title="Environment external URL"> <i class="fa fa-external-link" aria-hidden="true"></i> </a> diff --git a/app/assets/javascripts/environments/components/environment_item.js b/app/assets/javascripts/environments/components/environment_item.js index 93919d41c60..66ed10e19d1 100644 --- a/app/assets/javascripts/environments/components/environment_item.js +++ b/app/assets/javascripts/environments/components/environment_item.js @@ -1,24 +1,22 @@ import Timeago from 'timeago.js'; +import '../../lib/utils/text_utility'; import ActionsComponent from './environment_actions'; import ExternalUrlComponent from './environment_external_url'; import StopComponent from './environment_stop'; import RollbackComponent from './environment_rollback'; import TerminalButtonComponent from './environment_terminal_button'; -import '../../lib/utils/text_utility'; -import '../../vue_shared/components/commit'; +import CommitComponent from '../../vue_shared/components/commit'; /** * Envrionment Item Component * * Renders a table row for each environment. */ - const timeagoInstance = new Timeago(); export default { - components: { - 'commit-component': gl.CommitComponent, + 'commit-component': CommitComponent, 'actions-component': ActionsComponent, 'external-url-component': ExternalUrlComponent, 'stop-component': StopComponent, diff --git a/app/assets/javascripts/environments/components/environments_table.js b/app/assets/javascripts/environments/components/environments_table.js index 5f07b612b91..338dff40bc9 100644 --- a/app/assets/javascripts/environments/components/environments_table.js +++ b/app/assets/javascripts/environments/components/environments_table.js @@ -1,11 +1,11 @@ /** * Render environments table. */ -import EnvironmentItem from './environment_item'; +import EnvironmentTableRowComponent from './environment_item'; export default { components: { - 'environment-item': EnvironmentItem, + 'environment-item': EnvironmentTableRowComponent, }, props: { diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.js b/app/assets/javascripts/environments/folder/environments_folder_view.js index 7abcf6dbbea..8abbcf0c227 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.js +++ b/app/assets/javascripts/environments/folder/environments_folder_view.js @@ -1,20 +1,17 @@ -/* eslint-disable no-param-reassign, no-new */ +/* eslint-disable no-new */ /* global Flash */ +import Vue from 'vue'; import EnvironmentsService from '../services/environments_service'; import EnvironmentTable from '../components/environments_table'; import EnvironmentsStore from '../stores/environments_store'; - -const Vue = window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); -require('../../vue_shared/components/table_pagination'); -require('../../lib/utils/common_utils'); -require('../../vue_shared/vue_resource_interceptor'); +import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import '../../lib/utils/common_utils'; +import '../../vue_shared/vue_resource_interceptor'; export default Vue.component('environment-folder-view', { - components: { 'environment-table': EnvironmentTable, - 'table-pagination': gl.VueGlPagination, + 'table-pagination': TablePaginationComponent, }, data() { diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 76296c83d11..07040bf0d73 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -1,5 +1,8 @@ /* eslint-disable class-methods-use-this */ import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); export default class EnvironmentsService { constructor(endpoint) { diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index d3fe3872c56..3c3084f3b78 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -1,5 +1,4 @@ import '~/lib/utils/common_utils'; - /** * Environments Store. * diff --git a/app/assets/javascripts/filtered_search/container.js b/app/assets/javascripts/filtered_search/container.js new file mode 100644 index 00000000000..2243c4dd2c5 --- /dev/null +++ b/app/assets/javascripts/filtered_search/container.js @@ -0,0 +1,14 @@ +/* eslint-disable class-methods-use-this */ +let container = document; + +class FilteredSearchContainerClass { + set container(containerParam) { + container = containerParam; + } + + get container() { + return container; + } +} + +export default new FilteredSearchContainerClass(); diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 38ff3fb7158..98dcb697af9 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -45,7 +45,7 @@ require('./filtered_search_dropdown'); gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' ')); } - gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', '')); + gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container); } this.dismissDropdown(); this.dispatchInputEvent(); @@ -57,13 +57,15 @@ require('./filtered_search_dropdown'); const dropdownData = []; [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { - const { icon, hint, tag } = dropdownMenu.dataset; + const { icon, hint, tag, type } = dropdownMenu.dataset; if (icon && hint && tag) { - dropdownData.push({ - icon: `fa-${icon}`, - hint, - tag: `<${tag}>`, - }); + dropdownData.push( + Object.assign({ + icon: `fa-${icon}`, + hint, + tag: `<${tag}>`, + }, type && { type }), + ); } }); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index a5a6b56a0d3..432b0c0dfd2 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -1,3 +1,5 @@ +import FilteredSearchContainer from './container'; + (() => { class DropdownUtils { static getEscapedText(text) { @@ -51,14 +53,18 @@ static filterHint(input, item) { const updatedItem = item; - const searchInput = gl.DropdownUtils.getSearchInput(input); - let { lastToken } = gl.FilteredSearchTokenizer.processTokens(searchInput); - lastToken = lastToken.key || lastToken || ''; - - if (!lastToken || searchInput.split('').last() === ' ') { + const searchInput = gl.DropdownUtils.getSearchQuery(input); + const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); + const lastKey = lastToken.key || lastToken || ''; + const allowMultiple = item.type === 'array'; + const itemInExistingTokens = tokens.some(t => t.key === item.hint); + + if (!allowMultiple && itemInExistingTokens) { + updatedItem.droplab_hidden = true; + } else if (!lastKey || searchInput.split('').last() === ' ') { updatedItem.droplab_hidden = false; - } else if (lastToken) { - const split = lastToken.split(':'); + } else if (lastKey) { + const split = lastKey.split(':'); const tokenName = split[0].split(' ').last(); const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; @@ -81,7 +87,8 @@ // Determines the full search query (visual tokens + input) static getSearchQuery(untilInput = false) { - const tokens = [].slice.call(document.querySelectorAll('.tokens-container li')); + const container = FilteredSearchContainer.container; + const tokens = [].slice.call(container.querySelectorAll('.tokens-container li')); const values = []; if (untilInput) { @@ -110,7 +117,7 @@ const { isLastVisualTokenValid } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - const input = document.querySelector('.filtered-search'); + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); const inputValue = input && input.value; if (isLastVisualTokenValid) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index 134bdc6ad80..e7bf530d343 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -38,6 +38,7 @@ gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true); } + this.resetFilters(); this.dismissDropdown(); this.dispatchInputEvent(); } @@ -107,7 +108,7 @@ const hook = this.getCurrentHook(); if (hook) { - const data = hook.list.data; + const data = hook.list.data || []; const results = data.map((o) => { const updated = o; updated.droplab_hidden = false; 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 d37c812c1f7..5fbe0450bb8 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,12 +1,14 @@ /* global DropLab */ +import FilteredSearchContainer from './container'; (() => { class FilteredSearchDropdownManager { constructor(baseEndpoint = '', page) { + this.container = FilteredSearchContainer.container; this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.tokenizer = gl.FilteredSearchTokenizer; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - this.filteredSearchInput = document.querySelector('.filtered-search'); + this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; this.setupMapping(); @@ -31,35 +33,35 @@ author: { reference: null, gl: 'DropdownUser', - element: document.querySelector('#js-dropdown-author'), + element: this.container.querySelector('#js-dropdown-author'), }, assignee: { reference: null, gl: 'DropdownUser', - element: document.querySelector('#js-dropdown-assignee'), + element: this.container.querySelector('#js-dropdown-assignee'), }, milestone: { reference: null, gl: 'DropdownNonUser', extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'], - element: document.querySelector('#js-dropdown-milestone'), + element: this.container.querySelector('#js-dropdown-milestone'), }, label: { reference: null, gl: 'DropdownNonUser', extraArguments: [`${this.baseEndpoint}/labels.json`, '~'], - element: document.querySelector('#js-dropdown-label'), + element: this.container.querySelector('#js-dropdown-label'), }, hint: { reference: null, gl: 'DropdownHint', - element: document.querySelector('#js-dropdown-hint'), + element: this.container.querySelector('#js-dropdown-hint'), }, }; } static addWordToInput(tokenName, tokenValue = '', clicked = false) { - const input = document.querySelector('.filtered-search'); + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue); input.value = ''; @@ -75,13 +77,13 @@ updateDropdownOffset(key) { // Always align dropdown with the input field - let offset = this.filteredSearchInput.getBoundingClientRect().left - document.querySelector('.scroll-container').getBoundingClientRect().left; + let offset = this.filteredSearchInput.getBoundingClientRect().left - this.container.querySelector('.scroll-container').getBoundingClientRect().left; const maxInputWidth = 240; const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth; // Make sure offset never exceeds the input container - const offsetMaxWidth = document.querySelector('.scroll-container').clientWidth - currentDropdownWidth; + const offsetMaxWidth = this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth; if (offsetMaxWidth < offset) { offset = offsetMaxWidth; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index f885932bd91..c6bb7fda8f2 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,9 +1,12 @@ +import FilteredSearchContainer from './container'; + (() => { class FilteredSearchManager { constructor(page) { - this.filteredSearchInput = document.querySelector('.filtered-search'); - this.clearSearchButton = document.querySelector('.clear-search'); - this.tokensContainer = document.querySelector('.tokens-container'); + this.container = FilteredSearchContainer.container; + this.filteredSearchInput = this.container.querySelector('.filtered-search'); + this.clearSearchButton = this.container.querySelector('.clear-search'); + this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; if (this.filteredSearchInput) { @@ -37,6 +40,8 @@ this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.editTokenWrapper = this.editToken.bind(this); this.tokenChange = this.tokenChange.bind(this); + this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); + this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this); this.filteredSearchInputForm = this.filteredSearchInput.form; this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); @@ -48,11 +53,13 @@ this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange); + this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.addEventListener('click', this.unselectEditTokensWrapper); + document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper); } @@ -66,11 +73,13 @@ this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); + this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.removeEventListener('click', this.unselectEditTokensWrapper); + document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper); } @@ -121,6 +130,26 @@ } } + addInputContainerFocus() { + const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + + if (inputContainer) { + inputContainer.classList.add('focus'); + } + } + + removeInputContainerFocus(e) { + const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); + const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; + const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; + + if (!isElementInFilteredSearch && !isElementInDynamicFilterDropdown && + !isElementInStaticFilterDropdown && inputContainer) { + inputContainer.classList.remove('focus'); + } + } + static selectToken(e) { const button = e.target.closest('.selectable'); @@ -132,7 +161,7 @@ } unselectEditTokens(e) { - const inputContainer = document.querySelector('.filtered-search-input-container'); + const inputContainer = this.container.querySelector('.filtered-search-input-container'); const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementTokensContainer = e.target.classList.contains('tokens-container'); 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 e6b53cd4b55..6d5df86f2a5 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -43,6 +43,10 @@ tokenKey: 'milestone', value: 'upcoming', }, { + url: 'milestone_title=%23started', + tokenKey: 'milestone', + value: 'started', + }, { url: 'label_name[]=No+Label', tokenKey: 'label', value: 'none', 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 320afa26130..a5657fc8720 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,6 +1,8 @@ +import FilteredSearchContainer from './container'; + class FilteredSearchVisualTokens { static getLastVisualTokenBeforeInput() { - const inputLi = document.querySelector('.input-token'); + const inputLi = FilteredSearchContainer.container.querySelector('.input-token'); const lastVisualToken = inputLi && inputLi.previousElementSibling; return { @@ -10,7 +12,7 @@ class FilteredSearchVisualTokens { } static unselectTokens() { - const otherTokens = document.querySelectorAll('.js-visual-token .selectable.selected'); + const otherTokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token .selectable.selected'); [].forEach.call(otherTokens, t => t.classList.remove('selected')); } @@ -24,7 +26,7 @@ class FilteredSearchVisualTokens { } static removeSelectedToken() { - const selected = document.querySelector('.js-visual-token .selected'); + const selected = FilteredSearchContainer.container.querySelector('.js-visual-token .selected'); if (selected) { const li = selected.closest('.js-visual-token'); @@ -54,8 +56,8 @@ class FilteredSearchVisualTokens { } li.querySelector('.name').innerText = name; - const tokensContainer = document.querySelector('.tokens-container'); - const input = document.querySelector('.filtered-search'); + const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); tokensContainer.insertBefore(li, input.parentElement); } @@ -77,14 +79,14 @@ class FilteredSearchVisualTokens { const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement; if (isLastVisualTokenValid) { - addVisualTokenElement(tokenName, tokenValue); + addVisualTokenElement(tokenName, tokenValue, false); } else { const previousTokenName = lastVisualToken.querySelector('.name').innerText; - const tokensContainer = document.querySelector('.tokens-container'); + const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); const value = tokenValue || tokenName; - addVisualTokenElement(previousTokenName, value); + addVisualTokenElement(previousTokenName, value, false); } } @@ -129,7 +131,7 @@ class FilteredSearchVisualTokens { } static tokenizeInput() { - const input = document.querySelector('.filtered-search'); + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); const { isLastVisualTokenValid } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); @@ -145,7 +147,7 @@ class FilteredSearchVisualTokens { } static editToken(token) { - const input = document.querySelector('.filtered-search'); + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); FilteredSearchVisualTokens.tokenizeInput(); @@ -157,7 +159,7 @@ class FilteredSearchVisualTokens { const name = token.querySelector('.name'); const value = token.querySelector('.value'); - if (token.classList.contains('filtered-search-token')) { + if (token.classList.contains('filtered-search-token') && value) { FilteredSearchVisualTokens.addFilterVisualToken(name.innerText); input.value = value.innerText; } else { @@ -174,9 +176,9 @@ class FilteredSearchVisualTokens { } static moveInputToTheRight() { - const input = document.querySelector('.filtered-search'); + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); const inputLi = input.parentElement; - const tokenContainer = document.querySelector('.tokens-container'); + const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); FilteredSearchVisualTokens.tokenizeInput(); diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js new file mode 100644 index 00000000000..6a028f299b1 --- /dev/null +++ b/app/assets/javascripts/group_name.js @@ -0,0 +1,40 @@ +const GROUP_LIMIT = 2; + +export default class GroupName { + constructor() { + this.titleContainer = document.querySelector('.title'); + this.groups = document.querySelectorAll('.group-path'); + this.groupTitle = document.querySelector('.group-title'); + this.toggle = null; + this.isHidden = false; + this.init(); + } + + init() { + if (this.groups.length > GROUP_LIMIT) { + this.groups[this.groups.length - 1].classList.remove('hidable'); + this.addToggle(); + } + this.render(); + } + + addToggle() { + const header = document.querySelector('.header-content'); + this.toggle = document.createElement('button'); + this.toggle.className = 'text-expander group-name-toggle'; + this.toggle.setAttribute('aria-label', 'Toggle full path'); + this.toggle.innerHTML = '...'; + this.toggle.addEventListener('click', this.toggleGroups.bind(this)); + header.insertBefore(this.toggle, this.titleContainer); + this.toggleGroups(); + } + + toggleGroups() { + this.isHidden = !this.isHidden; + this.groupTitle.classList.toggle('is-hidden'); + } + + render() { + this.titleContainer.classList.remove('initializing'); + } +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index ef4029a8623..47e675f537e 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -2,6 +2,7 @@ /* global Flash */ require('./flash'); +require('~/lib/utils/text_utility'); require('vendor/jquery.waitforimages'); require('./task_list'); @@ -50,20 +51,21 @@ class Issue { success: function(data, textStatus, jqXHR) { if ('id' in data) { $(document).trigger('issuable:change'); - const currentTotal = Number($('.issue_counter').text()); + let total = Number($('.issue_counter').text().replace(/[^\d]/, '')); if (isClose) { $('a.btn-close').addClass('hidden'); $('a.btn-reopen').removeClass('hidden'); $('div.status-box-closed').removeClass('hidden'); $('div.status-box-open').addClass('hidden'); - $('.issue_counter').text(currentTotal - 1); + total -= 1; } else { $('a.btn-reopen').addClass('hidden'); $('a.btn-close').removeClass('hidden'); $('div.status-box-closed').addClass('hidden'); $('div.status-box-open').removeClass('hidden'); - $('.issue_counter').text(currentTotal + 1); + total += 1; } + $('.issue_counter').text(gl.text.addDelimiter(total)); } else { new Flash(issueFailMessage, 'alert'); } diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index c648a0f076c..443fb3e0ca9 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -76,7 +76,7 @@ if (!selected.length) { data[abilityName].label_ids = ['']; } - $loading.fadeIn(); + $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); return $.ajax({ type: 'PUT', diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 66cc270ab4d..0e2af3df071 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -14,13 +14,13 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; <%= ci_success_icon %> <span> Deployed to - <a href="<%- url %>" target="_blank" class="environment"> + <a href="<%- url %>" target="_blank" rel="noopener noreferrer" class="environment"> <%- name %> </a> <span class="js-environment-timeago" data-toggle="tooltip" data-placement="top" data-title="<%- deployed_at_formatted %>"> <%- deployed_at %> </span> - <a class="js-environment-link" href="<%- external_url %>" target="_blank"> + <a class="js-environment-link" href="<%- external_url %>" target="_blank" rel="noopener noreferrer"> <i class="fa fa-external-link"></i> View on <%- external_url_formatted %> </a> @@ -176,7 +176,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; _this.opts.ci_sha = data.sha; _this.updateCommitUrls(data.sha); } - if (showNotification) { + if (showNotification && data.status) { status = _this.ciLabelForStatus(data.status); if (status === "preparing") { title = _this.opts.ci_title.preparing; diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 4c4f94cb9f3..40e977df693 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -19,7 +19,7 @@ } $els.each(function(i, dropdown) { - var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, useId, showMenuAbove; + var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); milestonesUrl = $dropdown.data('milestones'); @@ -29,6 +29,7 @@ showAny = $dropdown.data('show-any'); showMenuAbove = $dropdown.data('showMenuAbove'); showUpcoming = $dropdown.data('show-upcoming'); + showStarted = $dropdown.data('show-started'); useId = $dropdown.data('use-id'); defaultLabel = $dropdown.data('default-label'); issuableId = $dropdown.data('issuable-id'); @@ -71,6 +72,13 @@ title: 'Upcoming' }); } + if (showStarted) { + extraOptions.push({ + id: -3, + name: '#started', + title: 'Started' + }); + } if (extraOptions.length) { extraOptions.push('divider'); } @@ -151,7 +159,7 @@ } $dropdown.trigger('loading.gl.dropdown'); - $loading.fadeIn(); + $loading.removeClass('hidden').fadeIn(); gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) .then(function () { @@ -163,7 +171,7 @@ data = {}; data[abilityName] = {}; data[abilityName].milestone_id = selected != null ? selected : null; - $loading.fadeIn(); + $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); return $.ajax({ type: 'PUT', diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js new file mode 100644 index 00000000000..b3ce9310417 --- /dev/null +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -0,0 +1,6 @@ +import PrometheusGraph from './prometheus_graph'; + +document.addEventListener('DOMContentLoaded', function onLoad() { + document.removeEventListener('DOMContentLoaded', onLoad, false); + return new PrometheusGraph(); +}, false); diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index 71eb746edac..fcffc11a2df 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -2,10 +2,9 @@ /* global Flash */ import d3 from 'd3'; -import _ from 'underscore'; import statusCodes from '~/lib/utils/http_status'; -import '~/lib/utils/common_utils'; -import '~/flash'; +import '../lib/utils/common_utils'; +import '../flash'; const prometheusGraphsContainer = '.prometheus-graph'; const metricsEndpoint = 'metrics.json'; @@ -31,22 +30,21 @@ class PrometheusGraph { } createGraph() { - const self = this; - _.each(this.data, (value, key) => { - if (value.length > 0 && (key === 'cpu_values' || key === 'memory_values')) { - self.plotValues(value, key); + Object.keys(this.data).forEach((key) => { + const value = this.data[key]; + if (value.length > 0) { + this.plotValues(value, key); } }); } init() { - const self = this; this.getData().then((metricsResponse) => { - if (metricsResponse === {}) { + if (Object.keys(metricsResponse).length === 0) { new Flash('Empty metrics', 'alert'); } else { - self.transformData(metricsResponse); - self.createGraph(); + this.transformData(metricsResponse); + this.createGraph(); } }); } @@ -321,12 +319,14 @@ class PrometheusGraph { transformData(metricsResponse) { const metricTypes = {}; - _.each(metricsResponse.metrics, (value, key) => { - const metricValues = value[0].values; - metricTypes[key] = _.map(metricValues, metric => ({ - time: new Date(metric[0] * 1000), - value: metric[1], - })); + Object.keys(metricsResponse.metrics).forEach((key) => { + if (key === 'cpu_values' || key === 'memory_values') { + const metricValues = (metricsResponse.metrics[key])[0]; + metricTypes[key] = metricValues.values.map(metric => ({ + time: new Date(metric[0] * 1000), + value: metric[1], + })); + } }); this.data = metricTypes; } diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 4ccea0624ee..c38bc762675 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -25,7 +25,6 @@ bindEvents() { $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm); $('#user_notification_email').on('change', this.submitForm); - $('#user_notified_of_own_activity').on('change', this.submitForm); $('.update-username').on('ajax:before', this.beforeUpdateUsername); $('.update-username').on('ajax:complete', this.afterUpdateUsername); $('.update-notifications').on('ajax:success', this.onUpdateNotifs); diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js index caaf6484a34..8be58023c84 100644 --- a/app/assets/javascripts/todos.js +++ b/app/assets/javascripts/todos.js @@ -5,6 +5,7 @@ class Todos { constructor() { this.initFilters(); this.bindEvents(); + this.todo_ids = []; this.cleanupWrapper = this.cleanup.bind(this); document.addEventListener('beforeunload', this.cleanupWrapper); @@ -17,16 +18,16 @@ class Todos { unbindEvents() { $('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper); - $('.js-todos-mark-all').off('click', this.allDoneClickedWrapper); + $('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper); $('.todo').off('click', this.goToTodoUrl); } bindEvents() { this.updateRowStateClickedWrapper = this.updateRowStateClicked.bind(this); - this.allDoneClickedWrapper = this.allDoneClicked.bind(this); + this.updateAllStateClickedWrapper = this.updateAllStateClicked.bind(this); $('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper); - $('.js-todos-mark-all').on('click', this.allDoneClickedWrapper); + $('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper); $('.todo').on('click', this.goToTodoUrl); } @@ -57,14 +58,14 @@ class Todos { e.preventDefault(); const target = e.target; - target.setAttribute('disabled', ''); + target.setAttribute('disabled', true); target.classList.add('disabled'); $.ajax({ type: 'POST', - url: target.getAttribute('href'), + url: target.dataset.href, dataType: 'json', data: { - '_method': target.getAttribute('data-method'), + '_method': target.dataset.method, }, success: (data) => { this.updateRowState(target); @@ -73,25 +74,6 @@ class Todos { }); } - allDoneClicked(e) { - e.preventDefault(); - const $target = $(e.currentTarget); - $target.disable(); - $.ajax({ - type: 'POST', - url: $target.attr('href'), - dataType: 'json', - data: { - '_method': 'delete', - }, - success: (data) => { - $target.remove(); - $('.js-todos-all').html('<div class="nothing-here-block">You\'re all done!</div>'); - this.updateBadges(data); - }, - }); - } - updateRowState(target) { const row = target.closest('li'); const restoreBtn = row.querySelector('.js-undo-todo'); @@ -112,6 +94,41 @@ class Todos { } } + updateAllStateClicked(e) { + e.preventDefault(); + + const target = e.currentTarget; + const requestData = { '_method': target.dataset.method, ids: this.todo_ids }; + target.setAttribute('disabled', true); + target.classList.add('disabled'); + $.ajax({ + type: 'POST', + url: target.dataset.href, + dataType: 'json', + data: requestData, + success: (data) => { + this.updateAllState(target, data); + return this.updateBadges(data); + }, + }); + } + + updateAllState(target, data) { + const markAllDoneBtn = document.querySelector('.js-todos-mark-all'); + const undoAllBtn = document.querySelector('.js-todos-undo-all'); + const todoListContainer = document.querySelector('.js-todos-list-container'); + const nothingHereContainer = document.querySelector('.js-nothing-here-container'); + + target.removeAttribute('disabled'); + target.classList.remove('disabled'); + + this.todo_ids = (target === markAllDoneBtn) ? data.updated_ids : []; + undoAllBtn.classList.toggle('hidden'); + markAllDoneBtn.classList.toggle('hidden'); + todoListContainer.classList.toggle('hidden'); + nothingHereContainer.classList.toggle('hidden'); + } + updateBadges(data) { $(document).trigger('todo:toggle', data.count); document.querySelector('.todos-pending .badge').innerHTML = data.count; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index c7a57b47834..eb897e9dfe9 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -53,7 +53,7 @@ $loading = $block.find('.block-loading').fadeOut(); var updateIssueBoardsIssue = function () { - $loading.fadeIn(); + $loading.removeClass('hidden').fadeIn(); gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) .then(function () { $loading.fadeOut(); @@ -90,7 +90,7 @@ data = {}; data[abilityName] = {}; data[abilityName].assignee_id = selected != null ? selected : null; - $loading.fadeIn(); + $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); return $.ajax({ type: 'PUT', diff --git a/app/assets/javascripts/vue_pipelines_index/components/async_button.js b/app/assets/javascripts/vue_pipelines_index/components/async_button.js new file mode 100644 index 00000000000..aaebf29d8ae --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/components/async_button.js @@ -0,0 +1,92 @@ +/* eslint-disable no-new, no-alert */ +/* global Flash */ +import '~/flash'; +import eventHub from '../event_hub'; + +export default { + props: { + endpoint: { + type: String, + required: true, + }, + + service: { + type: Object, + required: true, + }, + + title: { + type: String, + required: true, + }, + + icon: { + type: String, + required: true, + }, + + cssClass: { + type: String, + required: true, + }, + + confirmActionMessage: { + type: String, + required: false, + }, + }, + + data() { + return { + isLoading: false, + }; + }, + + computed: { + iconClass() { + return `fa fa-${this.icon}`; + }, + + buttonClass() { + return `btn has-tooltip ${this.cssClass}`; + }, + }, + + methods: { + onClick() { + if (this.confirmActionMessage && confirm(this.confirmActionMessage)) { + this.makeRequest(); + } else if (!this.confirmActionMessage) { + this.makeRequest(); + } + }, + + makeRequest() { + this.isLoading = true; + + this.service.postAction(this.endpoint) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshPipelines'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, + }, + + template: ` + <button + type="button" + @click="onClick" + :class="buttonClass" + :title="title" + :aria-label="title" + data-placement="top" + :disabled="isLoading"> + <i :class="iconClass" aria-hidden="true"/> + <i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading" /> + </button> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js b/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js new file mode 100644 index 00000000000..4e183d5c8ec --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js @@ -0,0 +1,56 @@ +export default { + props: [ + 'pipeline', + ], + computed: { + user() { + return !!this.pipeline.user; + }, + }, + template: ` + <td> + <a + :href="pipeline.path" + class="js-pipeline-url-link"> + <span class="pipeline-id">#{{pipeline.id}}</span> + </a> + <span>by</span> + <a + class="js-pipeline-url-user" + v-if="user" + :href="pipeline.user.web_url"> + <img + v-if="user" + class="avatar has-tooltip s20 " + :title="pipeline.user.name" + data-container="body" + :src="pipeline.user.avatar_url" + > + </a> + <span + v-if="!user" + class="js-pipeline-url-api api monospace"> + API + </span> + <span + v-if="pipeline.flags.latest" + class="js-pipeline-url-lastest label label-success has-tooltip" + title="Latest pipeline for this branch" + data-original-title="Latest pipeline for this branch"> + latest + </span> + <span + v-if="pipeline.flags.yaml_errors" + class="js-pipeline-url-yaml label label-danger has-tooltip" + :title="pipeline.yaml_errors" + :data-original-title="pipeline.yaml_errors"> + yaml invalid + </span> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck label label-warning"> + stuck + </span> + </td> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js b/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js new file mode 100644 index 00000000000..4bb2b048884 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js @@ -0,0 +1,71 @@ +/* eslint-disable no-new */ +/* global Flash */ +import '~/flash'; +import playIconSvg from 'icons/_icon_play.svg'; +import eventHub from '../event_hub'; + +export default { + props: { + actions: { + type: Array, + required: true, + }, + + service: { + type: Object, + required: true, + }, + }, + + data() { + return { + playIconSvg, + isLoading: false, + }; + }, + + methods: { + onClickAction(endpoint) { + this.isLoading = true; + + this.service.postAction(endpoint) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshPipelines'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, + }, + + template: ` + <div class="btn-group" v-if="actions"> + <button + type="button" + class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions" + title="Manual job" + data-toggle="dropdown" + data-placement="top" + aria-label="Manual job" + :disabled="isLoading"> + ${playIconSvg} + <i class="fa fa-caret-down" aria-hidden="true"></i> + <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> + </button> + + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="action in actions"> + <button + type="button" + class="js-pipeline-action-link no-btn" + @click="onClickAction(action.path)"> + ${playIconSvg} + <span>{{action.name}}</span> + </button> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js b/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js new file mode 100644 index 00000000000..3555040d60f --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js @@ -0,0 +1,32 @@ +export default { + props: { + artifacts: { + type: Array, + required: true, + }, + }, + + template: ` + <div class="btn-group" role="group"> + <button + class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download" + title="Artifacts" + data-placement="top" + data-toggle="dropdown" + aria-label="Artifacts"> + <i class="fa fa-download" aria-hidden="true"></i> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="artifact in artifacts"> + <a + rel="nofollow" + :href="artifact.path"> + <i class="fa fa-download" aria-hidden="true"></i> + <span>Download {{artifact.name}} artifacts</span> + </a> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/stage.js b/app/assets/javascripts/vue_pipelines_index/components/stage.js new file mode 100644 index 00000000000..a2c29002707 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/components/stage.js @@ -0,0 +1,116 @@ +/* global Flash */ +import canceledSvg from 'icons/_icon_status_canceled_borderless.svg'; +import createdSvg from 'icons/_icon_status_created_borderless.svg'; +import failedSvg from 'icons/_icon_status_failed_borderless.svg'; +import manualSvg from 'icons/_icon_status_manual_borderless.svg'; +import pendingSvg from 'icons/_icon_status_pending_borderless.svg'; +import runningSvg from 'icons/_icon_status_running_borderless.svg'; +import skippedSvg from 'icons/_icon_status_skipped_borderless.svg'; +import successSvg from 'icons/_icon_status_success_borderless.svg'; +import warningSvg from 'icons/_icon_status_warning_borderless.svg'; + +export default { + data() { + const svgsDictionary = { + icon_status_canceled: canceledSvg, + icon_status_created: createdSvg, + icon_status_failed: failedSvg, + icon_status_manual: manualSvg, + icon_status_pending: pendingSvg, + icon_status_running: runningSvg, + icon_status_skipped: skippedSvg, + icon_status_success: successSvg, + icon_status_warning: warningSvg, + }; + + return { + builds: '', + spinner: '<span class="fa fa-spinner fa-spin"></span>', + svg: svgsDictionary[this.stage.status.icon], + }; + }, + + props: { + stage: { + type: Object, + required: true, + }, + }, + + updated() { + if (this.builds) { + this.stopDropdownClickPropagation(); + } + }, + + methods: { + fetchBuilds(e) { + const ariaExpanded = e.currentTarget.attributes['aria-expanded']; + + if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null; + + return this.$http.get(this.stage.dropdown_path) + .then((response) => { + this.builds = JSON.parse(response.body).html; + }, () => { + const flash = new Flash('Something went wrong on our end.'); + return flash; + }); + }, + + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')).on('click', (e) => { + e.stopPropagation(); + }); + }, + }, + computed: { + buildsOrSpinner() { + return this.builds ? this.builds : this.spinner; + }, + dropdownClass() { + if (this.builds) return 'js-builds-dropdown-container'; + return 'js-builds-dropdown-loading builds-dropdown-loading'; + }, + buildStatus() { + return `Build: ${this.stage.status.label}`; + }, + tooltip() { + return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; + }, + triggerButtonClass() { + return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; + }, + }, + template: ` + <div> + <button + @click="fetchBuilds($event)" + :class="triggerButtonClass" + :title="stage.title" + data-placement="top" + data-toggle="dropdown" + type="button" + :aria-label="stage.title"> + <span v-html="svg" aria-hidden="true"></span> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> + <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> + <div class="arrow-up" aria-hidden="true"></div> + <div + :class="dropdownClass" + class="js-builds-dropdown-list scrollable-menu" + v-html="buildsOrSpinner"> + </div> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/status.js b/app/assets/javascripts/vue_pipelines_index/components/status.js new file mode 100644 index 00000000000..21a281af438 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/components/status.js @@ -0,0 +1,60 @@ +import canceledSvg from 'icons/_icon_status_canceled.svg'; +import createdSvg from 'icons/_icon_status_created.svg'; +import failedSvg from 'icons/_icon_status_failed.svg'; +import manualSvg from 'icons/_icon_status_manual.svg'; +import pendingSvg from 'icons/_icon_status_pending.svg'; +import runningSvg from 'icons/_icon_status_running.svg'; +import skippedSvg from 'icons/_icon_status_skipped.svg'; +import successSvg from 'icons/_icon_status_success.svg'; +import warningSvg from 'icons/_icon_status_warning.svg'; + +export default { + props: { + pipeline: { + type: Object, + required: true, + }, + }, + + data() { + const svgsDictionary = { + icon_status_canceled: canceledSvg, + icon_status_created: createdSvg, + icon_status_failed: failedSvg, + icon_status_manual: manualSvg, + icon_status_pending: pendingSvg, + icon_status_running: runningSvg, + icon_status_skipped: skippedSvg, + icon_status_success: successSvg, + icon_status_warning: warningSvg, + }; + + return { + svg: svgsDictionary[this.pipeline.details.status.icon], + }; + }, + + computed: { + cssClasses() { + return `ci-status ci-${this.pipeline.details.status.group}`; + }, + + detailsPath() { + const { status } = this.pipeline.details; + return status.has_details ? status.details_path : false; + }, + + content() { + return `${this.svg} ${this.pipeline.details.status.text}`; + }, + }, + template: ` + <td class="commit-link"> + <a + :class="cssClasses" + :href="detailsPath" + v-html="content"> + </a> + </td> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/time_ago.js b/app/assets/javascripts/vue_pipelines_index/components/time_ago.js new file mode 100644 index 00000000000..498d0715f54 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/components/time_ago.js @@ -0,0 +1,71 @@ +import iconTimerSvg from 'icons/_icon_timer.svg'; +import '../../lib/utils/datetime_utility'; + +export default { + data() { + return { + currentTime: new Date(), + iconTimerSvg, + }; + }, + props: ['pipeline'], + computed: { + timeAgo() { + return gl.utils.getTimeago(); + }, + localTimeFinished() { + return gl.utils.formatDate(this.pipeline.details.finished_at); + }, + timeStopped() { + const changeTime = this.currentTime; + const options = { + weekday: 'long', + year: 'numeric', + month: 'short', + day: 'numeric', + }; + options.timeZoneName = 'short'; + const finished = this.pipeline.details.finished_at; + if (!finished && changeTime) return false; + return ({ words: this.timeAgo.format(finished) }); + }, + duration() { + const { duration } = this.pipeline.details; + const date = new Date(duration * 1000); + + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); + + if (hh < 10) hh = `0${hh}`; + if (mm < 10) mm = `0${mm}`; + if (ss < 10) ss = `0${ss}`; + + if (duration !== null) return `${hh}:${mm}:${ss}`; + return false; + }, + }, + methods: { + changeTime() { + this.currentTime = new Date(); + }, + }, + template: ` + <td class="pipelines-time-ago"> + <p class="duration" v-if='duration'> + <span v-html="iconTimerSvg"></span> + {{duration}} + </p> + <p class="finished-at" v-if='timeStopped'> + <i class="fa fa-calendar"></i> + <time + data-toggle="tooltip" + data-placement="top" + data-container="body" + :data-original-title='localTimeFinished'> + {{timeStopped.words}} + </time> + </p> + </td> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/event_hub.js b/app/assets/javascripts/vue_pipelines_index/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/vue_pipelines_index/index.js b/app/assets/javascripts/vue_pipelines_index/index.js index a90bd1518e9..b4e2d3a1143 100644 --- a/app/assets/javascripts/vue_pipelines_index/index.js +++ b/app/assets/javascripts/vue_pipelines_index/index.js @@ -1,29 +1,28 @@ -/* eslint-disable no-param-reassign */ -/* global Vue, VueResource, gl */ -window.Vue = require('vue'); +import PipelinesStore from './stores/pipelines_store'; +import PipelinesComponent from './pipelines'; +import '../vue_shared/vue_resource_interceptor'; + +const Vue = window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -require('../lib/utils/common_utils'); -require('../vue_shared/vue_resource_interceptor'); -require('./pipelines'); $(() => new Vue({ el: document.querySelector('.vue-pipelines-index'), data() { const project = document.querySelector('.pipelines'); + const store = new PipelinesStore(); return { - scope: project.dataset.url, - store: new gl.PipelineStore(), + store, + endpoint: project.dataset.url, }; }, components: { - 'vue-pipelines': gl.VuePipelines, + 'vue-pipelines': PipelinesComponent, }, template: ` <vue-pipelines - :scope="scope" - :store="store"> - </vue-pipelines> + :endpoint="endpoint" + :store="store" /> `, })); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js deleted file mode 100644 index 891f1f17fb3..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js +++ /dev/null @@ -1,119 +0,0 @@ -/* global Vue, Flash, gl */ -/* eslint-disable no-param-reassign, no-alert */ -const playIconSvg = require('icons/_icon_play.svg'); - -((gl) => { - gl.VuePipelineActions = Vue.extend({ - props: ['pipeline'], - computed: { - actions() { - return this.pipeline.details.manual_actions.length > 0; - }, - artifacts() { - return this.pipeline.details.artifacts.length > 0; - }, - }, - methods: { - download(name) { - return `Download ${name} artifacts`; - }, - - /** - * Shows a dialog when the user clicks in the cancel button. - * We need to prevent the default behavior and stop propagation because the - * link relies on UJS. - * - * @param {Event} event - */ - confirmAction(event) { - if (!confirm('Are you sure you want to cancel this pipeline?')) { - event.preventDefault(); - event.stopPropagation(); - } - }, - }, - - data() { - return { playIconSvg }; - }, - - template: ` - <td class="pipeline-actions"> - <div class="pull-right"> - <div class="btn-group"> - <div class="btn-group" v-if="actions"> - <button - class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions" - data-toggle="dropdown" - title="Manual job" - data-placement="top" - aria-label="Manual job"> - <span v-html="playIconSvg" aria-hidden="true"></span> - <i class="fa fa-caret-down" aria-hidden="true"></i> - </button> - <ul class="dropdown-menu dropdown-menu-align-right"> - <li v-for='action in pipeline.details.manual_actions'> - <a - rel="nofollow" - data-method="post" - :href="action.path" > - <span v-html="playIconSvg" aria-hidden="true"></span> - <span>{{action.name}}</span> - </a> - </li> - </ul> - </div> - - <div class="btn-group" v-if="artifacts"> - <button - class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download" - title="Artifacts" - data-placement="top" - data-toggle="dropdown" - aria-label="Artifacts"> - <i class="fa fa-download" aria-hidden="true"></i> - <i class="fa fa-caret-down" aria-hidden="true"></i> - </button> - <ul class="dropdown-menu dropdown-menu-align-right"> - <li v-for='artifact in pipeline.details.artifacts'> - <a - rel="nofollow" - :href="artifact.path"> - <i class="fa fa-download" aria-hidden="true"></i> - <span>{{download(artifact.name)}}</span> - </a> - </li> - </ul> - </div> - <div class="btn-group" v-if="pipeline.flags.retryable"> - <a - class="btn btn-default btn-retry has-tooltip" - title="Retry" - rel="nofollow" - data-method="post" - data-placement="top" - data-toggle="dropdown" - :href='pipeline.retry_path' - aria-label="Retry"> - <i class="fa fa-repeat" aria-hidden="true"></i> - </a> - </div> - <div class="btn-group" v-if="pipeline.flags.cancelable"> - <a - class="btn btn-remove has-tooltip" - title="Cancel" - rel="nofollow" - data-method="post" - data-placement="top" - data-toggle="dropdown" - :href='pipeline.cancel_path' - aria-label="Cancel"> - <i class="fa fa-remove" aria-hidden="true"></i> - </a> - </div> - </div> - </div> - </td> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_url.js b/app/assets/javascripts/vue_pipelines_index/pipeline_url.js deleted file mode 100644 index ae5649f0519..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/pipeline_url.js +++ /dev/null @@ -1,63 +0,0 @@ -/* global Vue, gl */ -/* eslint-disable no-param-reassign */ - -((gl) => { - gl.VuePipelineUrl = Vue.extend({ - props: [ - 'pipeline', - ], - computed: { - user() { - return !!this.pipeline.user; - }, - }, - template: ` - <td> - <a :href='pipeline.path'> - <span class="pipeline-id">#{{pipeline.id}}</span> - </a> - <span>by</span> - <a - v-if='user' - :href='pipeline.user.web_url' - > - <img - v-if='user' - class="avatar has-tooltip s20 " - :title='pipeline.user.name' - data-container="body" - :src='pipeline.user.avatar_url' - > - </a> - <span - v-if='!user' - class="api monospace" - > - API - </span> - <span - v-if='pipeline.flags.latest' - class="label label-success has-tooltip" - title="Latest pipeline for this branch" - data-original-title="Latest pipeline for this branch" - > - latest - </span> - <span - v-if='pipeline.flags.yaml_errors' - class="label label-danger has-tooltip" - :title='pipeline.yaml_errors' - :data-original-title='pipeline.yaml_errors' - > - yaml invalid - </span> - <span - v-if='pipeline.flags.stuck' - class="label label-warning" - > - stuck - </span> - </td> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js b/app/assets/javascripts/vue_pipelines_index/pipelines.js index 601ef41e917..f389e5e4950 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipelines.js +++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js @@ -1,87 +1,121 @@ -/* global Vue, gl */ -/* eslint-disable no-param-reassign */ +/* global Flash */ +/* eslint-disable no-new */ +import '~/flash'; +import Vue from 'vue'; +import PipelinesService from './services/pipelines_service'; +import eventHub from './event_hub'; +import PipelinesTableComponent from '../vue_shared/components/pipelines_table'; +import TablePaginationComponent from '../vue_shared/components/table_pagination'; -window.Vue = require('vue'); -require('../vue_shared/components/table_pagination'); -require('./store'); -require('../vue_shared/components/pipelines_table'); -const CommitPipelinesStoreWithTimeAgo = require('../commit/pipelines/pipelines_store'); - -((gl) => { - gl.VuePipelines = Vue.extend({ - - components: { - 'gl-pagination': gl.VueGlPagination, - 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, +export default { + props: { + endpoint: { + type: String, + required: true, }, - data() { - return { - pipelines: [], - timeLoopInterval: '', - intervalId: '', - apiScope: 'all', - pageInfo: {}, - pagenum: 1, - count: {}, - pageRequest: false, - }; - }, - props: ['scope', 'store'], - created() { - const pagenum = gl.utils.getParameterByName('page'); - const scope = gl.utils.getParameterByName('scope'); - if (pagenum) this.pagenum = pagenum; - if (scope) this.apiScope = scope; - - this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope); + store: { + type: Object, + required: true, }, + }, + + components: { + 'gl-pagination': TablePaginationComponent, + 'pipelines-table-component': PipelinesTableComponent, + }, + + data() { + return { + state: this.store.state, + apiScope: 'all', + pagenum: 1, + pageRequest: false, + }; + }, + + created() { + this.service = new PipelinesService(this.endpoint); + + this.fetchPipelines(); + + eventHub.$on('refreshPipelines', this.fetchPipelines); + }, + + beforeUpdate() { + if (this.state.pipelines.length && this.$children) { + this.store.startTimeAgoLoops.call(this, Vue); + } + }, - beforeUpdate() { - if (this.pipelines.length && this.$children) { - CommitPipelinesStoreWithTimeAgo.startTimeAgoLoops.call(this, Vue); - } + beforeDestroyed() { + eventHub.$off('refreshPipelines'); + }, + + methods: { + /** + * Will change the page number and update the URL. + * + * @param {Number} pageNumber desired page to go to. + */ + change(pageNumber) { + const param = gl.utils.setParamInURL('page', pageNumber); + + gl.utils.visitUrl(param); + return param; }, - methods: { - /** - * Will change the page number and update the URL. - * - * @param {Number} pageNumber desired page to go to. - */ - change(pageNumber) { - const param = gl.utils.setParamInURL('page', pageNumber); - - gl.utils.visitUrl(param); - return param; - }, + fetchPipelines() { + const pageNumber = gl.utils.getParameterByName('page') || this.pagenum; + const scope = gl.utils.getParameterByName('scope') || this.apiScope; + + this.pageRequest = true; + return this.service.getPipelines(scope, pageNumber) + .then(resp => ({ + headers: resp.headers, + body: resp.json(), + })) + .then((response) => { + this.store.storeCount(response.body.count); + this.store.storePipelines(response.body.pipelines); + this.store.storePagination(response.headers); + }) + .then(() => { + this.pageRequest = false; + }) + .catch(() => { + this.pageRequest = false; + new Flash('An error occurred while fetching the pipelines, please reload the page again.'); + }); }, - template: ` - <div> - <div class="pipelines realtime-loading" v-if='pageRequest'> - <i class="fa fa-spinner fa-spin"></i> - </div> - - <div class="blank-state blank-state-no-icon" - v-if="!pageRequest && pipelines.length === 0"> - <h2 class="blank-state-title js-blank-state-title"> - No pipelines to show - </h2> - </div> - - <div class="table-holder" v-if='!pageRequest && pipelines.length'> - <pipelines-table-component :pipelines='pipelines'/> - </div> - - <gl-pagination - v-if='!pageRequest && pipelines.length && pageInfo.total > pageInfo.perPage' - :pagenum='pagenum' - :change='change' - :count='count.all' - :pageInfo='pageInfo' - > - </gl-pagination> + }, + template: ` + <div> + <div class="pipelines realtime-loading" v-if="pageRequest"> + <i class="fa fa-spinner fa-spin" aria-hidden="true"></i> + </div> + + <div class="blank-state blank-state-no-icon" + v-if="!pageRequest && state.pipelines.length === 0"> + <h2 class="blank-state-title js-blank-state-title"> + No pipelines to show + </h2> + </div> + + <div class="table-holder" v-if="!pageRequest && state.pipelines.length"> + <pipelines-table-component + :pipelines="state.pipelines" + :service="service"/> </div> - `, - }); -})(window.gl || (window.gl = {})); + + <gl-pagination + v-if="!pageRequest && state.pipelines.length && state.pageInfo.total > state.pageInfo.perPage" + :pagenum="pagenum" + :change="change" + :count="state.count.all" + :pageInfo="state.pageInfo" + > + </gl-pagination> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js b/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js new file mode 100644 index 00000000000..708f5068dd3 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js @@ -0,0 +1,44 @@ +/* eslint-disable class-methods-use-this */ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class PipelinesService { + + /** + * Commits and merge request endpoints need to be requested with `.json`. + * + * The url provided to request the pipelines in the new merge request + * page already has `.json`. + * + * @param {String} root + */ + constructor(root) { + let endpoint; + + if (root.indexOf('.json') === -1) { + endpoint = `${root}.json`; + } else { + endpoint = root; + } + + this.pipelines = Vue.resource(endpoint); + } + + getPipelines(scope, page) { + return this.pipelines.get({ scope, page }); + } + + /** + * Post request for all pipelines actions. + * Endpoint content type needs to be: + * `Content-Type:application/x-www-form-urlencoded` + * + * @param {String} endpoint + * @return {Promise} + */ + postAction(endpoint) { + return Vue.http.post(endpoint, {}, { emulateJSON: true }); + } +} diff --git a/app/assets/javascripts/vue_pipelines_index/stage.js b/app/assets/javascripts/vue_pipelines_index/stage.js deleted file mode 100644 index ae4f0b4a53b..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/stage.js +++ /dev/null @@ -1,119 +0,0 @@ -/* global Vue, Flash, gl */ -/* eslint-disable no-param-reassign */ -import canceledSvg from 'icons/_icon_status_canceled_borderless.svg'; -import createdSvg from 'icons/_icon_status_created_borderless.svg'; -import failedSvg from 'icons/_icon_status_failed_borderless.svg'; -import manualSvg from 'icons/_icon_status_manual_borderless.svg'; -import pendingSvg from 'icons/_icon_status_pending_borderless.svg'; -import runningSvg from 'icons/_icon_status_running_borderless.svg'; -import skippedSvg from 'icons/_icon_status_skipped_borderless.svg'; -import successSvg from 'icons/_icon_status_success_borderless.svg'; -import warningSvg from 'icons/_icon_status_warning_borderless.svg'; - -((gl) => { - gl.VueStage = Vue.extend({ - data() { - const svgsDictionary = { - icon_status_canceled: canceledSvg, - icon_status_created: createdSvg, - icon_status_failed: failedSvg, - icon_status_manual: manualSvg, - icon_status_pending: pendingSvg, - icon_status_running: runningSvg, - icon_status_skipped: skippedSvg, - icon_status_success: successSvg, - icon_status_warning: warningSvg, - }; - - return { - builds: '', - spinner: '<span class="fa fa-spinner fa-spin"></span>', - svg: svgsDictionary[this.stage.status.icon], - }; - }, - - props: { - stage: { - type: Object, - required: true, - }, - }, - - updated() { - if (this.builds) { - this.stopDropdownClickPropagation(); - } - }, - - methods: { - fetchBuilds(e) { - const areaExpanded = e.currentTarget.attributes['aria-expanded']; - - if (areaExpanded && (areaExpanded.textContent === 'true')) return null; - - return this.$http.get(this.stage.dropdown_path) - .then((response) => { - this.builds = JSON.parse(response.body).html; - }, () => { - const flash = new Flash('Something went wrong on our end.'); - return flash; - }); - }, - - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $(this.$el).on('click', '.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item', (e) => { - e.stopPropagation(); - }); - }, - }, - computed: { - buildsOrSpinner() { - return this.builds ? this.builds : this.spinner; - }, - dropdownClass() { - if (this.builds) return 'js-builds-dropdown-container'; - return 'js-builds-dropdown-loading builds-dropdown-loading'; - }, - buildStatus() { - return `Build: ${this.stage.status.label}`; - }, - tooltip() { - return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; - }, - triggerButtonClass() { - return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; - }, - }, - template: ` - <div> - <button - @click="fetchBuilds($event)" - :class="triggerButtonClass" - :title="stage.title" - data-placement="top" - data-toggle="dropdown" - type="button" - :aria-label="stage.title"> - <span v-html="svg" aria-hidden="true"></span> - <i class="fa fa-caret-down" aria-hidden="true"></i> - </button> - <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> - <div class="arrow-up" aria-hidden="true"></div> - <div - :class="dropdownClass" - class="js-builds-dropdown-list scrollable-menu" - v-html="buildsOrSpinner"> - </div> - </ul> - </div> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/status.js b/app/assets/javascripts/vue_pipelines_index/status.js deleted file mode 100644 index 8d9f83ac113..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/status.js +++ /dev/null @@ -1,64 +0,0 @@ -/* global Vue, gl */ -/* eslint-disable no-param-reassign */ - -import canceledSvg from 'icons/_icon_status_canceled.svg'; -import createdSvg from 'icons/_icon_status_created.svg'; -import failedSvg from 'icons/_icon_status_failed.svg'; -import manualSvg from 'icons/_icon_status_manual.svg'; -import pendingSvg from 'icons/_icon_status_pending.svg'; -import runningSvg from 'icons/_icon_status_running.svg'; -import skippedSvg from 'icons/_icon_status_skipped.svg'; -import successSvg from 'icons/_icon_status_success.svg'; -import warningSvg from 'icons/_icon_status_warning.svg'; - -((gl) => { - gl.VueStatusScope = Vue.extend({ - props: [ - 'pipeline', - ], - - data() { - const svgsDictionary = { - icon_status_canceled: canceledSvg, - icon_status_created: createdSvg, - icon_status_failed: failedSvg, - icon_status_manual: manualSvg, - icon_status_pending: pendingSvg, - icon_status_running: runningSvg, - icon_status_skipped: skippedSvg, - icon_status_success: successSvg, - icon_status_warning: warningSvg, - }; - - return { - svg: svgsDictionary[this.pipeline.details.status.icon], - }; - }, - - computed: { - cssClasses() { - const cssObject = { 'ci-status': true }; - cssObject[`ci-${this.pipeline.details.status.group}`] = true; - return cssObject; - }, - - detailsPath() { - const { status } = this.pipeline.details; - return status.has_details ? status.details_path : false; - }, - - content() { - return `${this.svg} ${this.pipeline.details.status.text}`; - }, - }, - template: ` - <td class="commit-link"> - <a - :class="cssClasses" - :href="detailsPath" - v-html="content"> - </a> - </td> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/store.js b/app/assets/javascripts/vue_pipelines_index/store.js deleted file mode 100644 index 909007267b9..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/store.js +++ /dev/null @@ -1,31 +0,0 @@ -/* global gl, Flash */ -/* eslint-disable no-param-reassign */ - -((gl) => { - const pageValues = (headers) => { - const normalized = gl.utils.normalizeHeaders(headers); - const paginationInfo = gl.utils.parseIntPagination(normalized); - return paginationInfo; - }; - - gl.PipelineStore = class { - fetchDataLoop(Vue, pageNum, url, apiScope) { - this.pageRequest = true; - - return this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`) - .then((response) => { - const pageInfo = pageValues(response.headers); - this.pageInfo = Object.assign({}, this.pageInfo, pageInfo); - - const res = JSON.parse(response.body); - this.count = Object.assign({}, this.count, res.count); - this.pipelines = Object.assign([], this.pipelines, res.pipelines); - - this.pageRequest = false; - }, () => { - this.pageRequest = false; - return new Flash('An error occurred while fetching the pipelines, please reload the page again.'); - }); - } - }; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_store.js b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js index 9d6abbc057e..6f19e46c7dc 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_store.js +++ b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js @@ -1,33 +1,48 @@ /* eslint-disable no-underscore-dangle*/ -/** - * Pipelines' Store for commits view. - * - * Used to store the Pipelines rendered in the commit view in the pipelines table. - */ -const VueRealtimeListener = require('../../vue_realtime_listener'); +import VueRealtimeListener from '../../vue_realtime_listener'; gl.VueRealtimeListener = VueRealtimeListener; -class PipelinesStore { +export default class PipelinesStore { constructor() { this.state = {}; + this.state.pipelines = []; + this.state.count = {}; + this.state.pageInfo = {}; } storePipelines(pipelines = []) { this.state.pipelines = pipelines; + } + + storeCount(count = {}) { + this.state.count = count; + } - return pipelines; + storePagination(pagination = {}) { + let paginationInfo; + + if (Object.keys(pagination).length) { + const normalizedHeaders = gl.utils.normalizeHeaders(pagination); + paginationInfo = gl.utils.parseIntPagination(normalizedHeaders); + } else { + paginationInfo = pagination; + } + + this.state.pageInfo = paginationInfo; } /** + * FIXME: Move this inside the component. + * * Once the data is received we will start the time ago loops. * * Everytime a request is made like retry or cancel a pipeline, every 10 seconds we * update the time to show how long as passed. * */ - static startTimeAgoLoops() { + startTimeAgoLoops() { const startTimeLoops = () => { this.timeLoopInterval = setInterval(() => { this.$children[0].$children.reduce((acc, component) => { @@ -46,5 +61,3 @@ class PipelinesStore { gl.VueRealtimeListener(removeIntervals, startIntervals); } } - -module.exports = PipelinesStore; diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js b/app/assets/javascripts/vue_pipelines_index/time_ago.js deleted file mode 100644 index a383570857d..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/time_ago.js +++ /dev/null @@ -1,78 +0,0 @@ -/* global Vue, gl */ -/* eslint-disable no-param-reassign */ - -window.Vue = require('vue'); -require('../lib/utils/datetime_utility'); - -const iconTimerSvg = require('../../../views/shared/icons/_icon_timer.svg'); - -((gl) => { - gl.VueTimeAgo = Vue.extend({ - data() { - return { - currentTime: new Date(), - iconTimerSvg, - }; - }, - props: ['pipeline'], - computed: { - timeAgo() { - return gl.utils.getTimeago(); - }, - localTimeFinished() { - return gl.utils.formatDate(this.pipeline.details.finished_at); - }, - timeStopped() { - const changeTime = this.currentTime; - const options = { - weekday: 'long', - year: 'numeric', - month: 'short', - day: 'numeric', - }; - options.timeZoneName = 'short'; - const finished = this.pipeline.details.finished_at; - if (!finished && changeTime) return false; - return ({ words: this.timeAgo.format(finished) }); - }, - duration() { - const { duration } = this.pipeline.details; - const date = new Date(duration * 1000); - - let hh = date.getUTCHours(); - let mm = date.getUTCMinutes(); - let ss = date.getSeconds(); - - if (hh < 10) hh = `0${hh}`; - if (mm < 10) mm = `0${mm}`; - if (ss < 10) ss = `0${ss}`; - - if (duration !== null) return `${hh}:${mm}:${ss}`; - return false; - }, - }, - methods: { - changeTime() { - this.currentTime = new Date(); - }, - }, - template: ` - <td class="pipelines-time-ago"> - <p class="duration" v-if='duration'> - <span v-html="iconTimerSvg"></span> - {{duration}} - </p> - <p class="finished-at" v-if='timeStopped'> - <i class="fa fa-calendar"></i> - <time - data-toggle="tooltip" - data-placement="top" - data-container="body" - :data-original-title='localTimeFinished'> - {{timeStopped.words}} - </time> - </p> - </td> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index 4381487b79e..fb68abd95a2 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -1,164 +1,157 @@ -/* global Vue */ -window.Vue = require('vue'); -const commitIconSvg = require('icons/_icon_commit.svg'); - -(() => { - window.gl = window.gl || {}; - - window.gl.CommitComponent = Vue.component('commit-component', { - - props: { - /** - * Indicates the existance of a tag. - * Used to render the correct icon, if true will render `fa-tag` icon, - * if false will render `fa-code-fork` icon. - */ - tag: { - type: Boolean, - required: false, - default: false, - }, - - /** - * If provided is used to render the branch name and url. - * Should contain the following properties: - * name - * ref_url - */ - commitRef: { - type: Object, - required: false, - default: () => ({}), - }, - - /** - * Used to link to the commit sha. - */ - commitUrl: { - type: String, - required: false, - default: '', - }, - - /** - * Used to show the commit short sha that links to the commit url. - */ - shortSha: { - type: String, - required: false, - default: '', - }, - - /** - * If provided shows the commit tile. - */ - title: { - type: String, - required: false, - default: '', - }, - - /** - * If provided renders information about the author of the commit. - * When provided should include: - * `avatar_url` to render the avatar icon - * `web_url` to link to user profile - * `username` to render alt and title tags - */ - author: { - type: Object, - required: false, - default: () => ({}), - }, +import commitIconSvg from 'icons/_icon_commit.svg'; + +export default { + props: { + /** + * Indicates the existance of a tag. + * Used to render the correct icon, if true will render `fa-tag` icon, + * if false will render `fa-code-fork` icon. + */ + tag: { + type: Boolean, + required: false, + default: false, }, - computed: { - /** - * Used to verify if all the properties needed to render the commit - * ref section were provided. - * - * TODO: Improve this! Use lodash _.has when we have it. - * - * @returns {Boolean} - */ - hasCommitRef() { - return this.commitRef && this.commitRef.name && this.commitRef.ref_url; - }, - - /** - * Used to verify if all the properties needed to render the commit - * author section were provided. - * - * TODO: Improve this! Use lodash _.has when we have it. - * - * @returns {Boolean} - */ - hasAuthor() { - return this.author && - this.author.avatar_url && - this.author.web_url && - this.author.username; - }, - - /** - * If information about the author is provided will return a string - * to be rendered as the alt attribute of the img tag. - * - * @returns {String} - */ - userImageAltDescription() { - return this.author && - this.author.username ? `${this.author.username}'s avatar` : null; - }, + /** + * If provided is used to render the branch name and url. + * Should contain the following properties: + * name + * ref_url + */ + commitRef: { + type: Object, + required: false, + default: () => ({}), }, - data() { - return { commitIconSvg }; + /** + * Used to link to the commit sha. + */ + commitUrl: { + type: String, + required: false, + default: '', }, - template: ` - <div class="branch-commit"> - - <div v-if="hasCommitRef" class="icon-container"> - <i v-if="tag" class="fa fa-tag"></i> - <i v-if="!tag" class="fa fa-code-fork"></i> - </div> - - <a v-if="hasCommitRef" - class="monospace branch-name" - :href="commitRef.ref_url"> - {{commitRef.name}} - </a> - - <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div> - - <a class="commit-id monospace" - :href="commitUrl"> - {{shortSha}} - </a> - - <p class="commit-title"> - <span v-if="title"> - <a v-if="hasAuthor" - class="avatar-image-container" - :href="author.web_url"> - <img - class="avatar has-tooltip s20" - :src="author.avatar_url" - :alt="userImageAltDescription" - :title="author.username" /> - </a> - - <a class="commit-row-message" - :href="commitUrl"> - {{title}} - </a> - </span> - <span v-else> - Cant find HEAD commit for this branch - </span> - </p> + /** + * Used to show the commit short sha that links to the commit url. + */ + shortSha: { + type: String, + required: false, + default: '', + }, + + /** + * If provided shows the commit tile. + */ + title: { + type: String, + required: false, + default: '', + }, + + /** + * If provided renders information about the author of the commit. + * When provided should include: + * `avatar_url` to render the avatar icon + * `web_url` to link to user profile + * `username` to render alt and title tags + */ + author: { + type: Object, + required: false, + default: () => ({}), + }, + }, + + computed: { + /** + * Used to verify if all the properties needed to render the commit + * ref section were provided. + * + * TODO: Improve this! Use lodash _.has when we have it. + * + * @returns {Boolean} + */ + hasCommitRef() { + return this.commitRef && this.commitRef.name && this.commitRef.ref_url; + }, + + /** + * Used to verify if all the properties needed to render the commit + * author section were provided. + * + * TODO: Improve this! Use lodash _.has when we have it. + * + * @returns {Boolean} + */ + hasAuthor() { + return this.author && + this.author.avatar_url && + this.author.web_url && + this.author.username; + }, + + /** + * If information about the author is provided will return a string + * to be rendered as the alt attribute of the img tag. + * + * @returns {String} + */ + userImageAltDescription() { + return this.author && + this.author.username ? `${this.author.username}'s avatar` : null; + }, + }, + + data() { + return { commitIconSvg }; + }, + + template: ` + <div class="branch-commit"> + + <div v-if="hasCommitRef" class="icon-container"> + <i v-if="tag" class="fa fa-tag"></i> + <i v-if="!tag" class="fa fa-code-fork"></i> </div> - `, - }); -})(); + + <a v-if="hasCommitRef" + class="monospace branch-name" + :href="commitRef.ref_url"> + {{commitRef.name}} + </a> + + <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div> + + <a class="commit-id monospace" + :href="commitUrl"> + {{shortSha}} + </a> + + <p class="commit-title"> + <span v-if="title"> + <a v-if="hasAuthor" + class="avatar-image-container" + :href="author.web_url"> + <img + class="avatar has-tooltip s20" + :src="author.avatar_url" + :alt="userImageAltDescription" + :title="author.username" /> + </a> + + <a class="commit-row-message" + :href="commitUrl"> + {{title}} + </a> + </span> + <span v-else> + Cant find HEAD commit for this branch + </span> + </p> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js b/app/assets/javascripts/vue_shared/components/pipelines_table.js index 0d8f85db965..afd8d7acf6b 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js @@ -1,52 +1,48 @@ -/* eslint-disable no-param-reassign */ -/* global Vue */ +import PipelinesTableRowComponent from './pipelines_table_row'; -require('./pipelines_table_row'); /** * Pipelines Table Component. * * Given an array of objects, renders a table. */ - -(() => { - window.gl = window.gl || {}; - gl.pipelines = gl.pipelines || {}; - - gl.pipelines.PipelinesTableComponent = Vue.component('pipelines-table-component', { - - props: { - pipelines: { - type: Array, - required: true, - default: () => ([]), - }, - +export default { + props: { + pipelines: { + type: Array, + required: true, + default: () => ([]), }, - components: { - 'pipelines-table-row-component': gl.pipelines.PipelinesTableRowComponent, + service: { + type: Object, + required: true, }, + }, + + components: { + 'pipelines-table-row-component': PipelinesTableRowComponent, + }, - template: ` - <table class="table ci-table"> - <thead> - <tr> - <th class="js-pipeline-status pipeline-status">Status</th> - <th class="js-pipeline-info pipeline-info">Pipeline</th> - <th class="js-pipeline-commit pipeline-commit">Commit</th> - <th class="js-pipeline-stages pipeline-stages">Stages</th> - <th class="js-pipeline-date pipeline-date"></th> - <th class="js-pipeline-actions pipeline-actions"></th> - </tr> - </thead> - <tbody> - <template v-for="model in pipelines" - v-bind:model="model"> - <tr is="pipelines-table-row-component" - :pipeline="model"></tr> - </template> - </tbody> - </table> - `, - }); -})(); + template: ` + <table class="table ci-table"> + <thead> + <tr> + <th class="js-pipeline-status pipeline-status">Status</th> + <th class="js-pipeline-info pipeline-info">Pipeline</th> + <th class="js-pipeline-commit pipeline-commit">Commit</th> + <th class="js-pipeline-stages pipeline-stages">Stages</th> + <th class="js-pipeline-date pipeline-date"></th> + <th class="js-pipeline-actions pipeline-actions"></th> + </tr> + </thead> + <tbody> + <template v-for="model in pipelines" + v-bind:model="model"> + <tr is="pipelines-table-row-component" + :pipeline="model" + :service="service"></tr> + </template> + </tbody> + </table> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index e5e88186a85..f5b3cb9214e 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -1,199 +1,228 @@ /* eslint-disable no-param-reassign */ -/* global Vue */ - -require('../../vue_pipelines_index/status'); -require('../../vue_pipelines_index/pipeline_url'); -require('../../vue_pipelines_index/stage'); -require('../../vue_pipelines_index/pipeline_actions'); -require('../../vue_pipelines_index/time_ago'); -require('./commit'); + +import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button'; +import PipelinesActionsComponent from '../../vue_pipelines_index/components/pipelines_actions'; +import PipelinesArtifactsComponent from '../../vue_pipelines_index/components/pipelines_artifacts'; +import PipelinesStatusComponent from '../../vue_pipelines_index/components/status'; +import PipelinesStageComponent from '../../vue_pipelines_index/components/stage'; +import PipelinesUrlComponent from '../../vue_pipelines_index/components/pipeline_url'; +import PipelinesTimeagoComponent from '../../vue_pipelines_index/components/time_ago'; +import CommitComponent from './commit'; + /** * Pipeline table row. * * Given the received object renders a table row in the pipelines' table. */ -(() => { - window.gl = window.gl || {}; - gl.pipelines = gl.pipelines || {}; - - gl.pipelines.PipelinesTableRowComponent = Vue.component('pipelines-table-row-component', { - - props: { - pipeline: { - type: Object, - required: true, - default: () => ({}), - }, +export default { + props: { + pipeline: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + }, + + components: { + 'async-button-component': AsyncButtonComponent, + 'pipelines-actions-component': PipelinesActionsComponent, + 'pipelines-artifacts-component': PipelinesArtifactsComponent, + 'commit-component': CommitComponent, + 'dropdown-stage': PipelinesStageComponent, + 'pipeline-url': PipelinesUrlComponent, + 'status-scope': PipelinesStatusComponent, + 'time-ago': PipelinesTimeagoComponent, + }, + + computed: { + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * This field needs a lot of verification, because of different possible cases: + * + * 1. person who is an author of a commit might be a GitLab user + * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar + * 3. If GitLab user does not have avatar he/she might have a Gravatar + * 4. If committer is not a GitLab User he/she can have a Gravatar + * 5. We do not have consistent API object in this case + * 6. We should improve API and the code + * + * @returns {Object|Undefined} + */ + commitAuthor() { + let commitAuthorInformation; + + // 1. person who is an author of a commit might be a GitLab user + if (this.pipeline && + this.pipeline.commit && + this.pipeline.commit.author) { + // 2. if person who is an author of a commit is a GitLab user + // he/she can have a GitLab avatar + if (this.pipeline.commit.author.avatar_url) { + commitAuthorInformation = this.pipeline.commit.author; + + // 3. If GitLab user does not have avatar he/she might have a Gravatar + } else if (this.pipeline.commit.author_gravatar_url) { + commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { + avatar_url: this.pipeline.commit.author_gravatar_url, + }); + } + } + + // 4. If committer is not a GitLab User he/she can have a Gravatar + if (this.pipeline && + this.pipeline.commit) { + commitAuthorInformation = { + avatar_url: this.pipeline.commit.author_gravatar_url, + web_url: `mailto:${this.pipeline.commit.author_email}`, + username: this.pipeline.commit.author_name, + }; + } + + return commitAuthorInformation; }, - components: { - 'commit-component': gl.CommitComponent, - 'pipeline-actions': gl.VuePipelineActions, - 'dropdown-stage': gl.VueStage, - 'pipeline-url': gl.VuePipelineUrl, - 'status-scope': gl.VueStatusScope, - 'time-ago': gl.VueTimeAgo, + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTag() { + if (this.pipeline.ref && + this.pipeline.ref.tag) { + return this.pipeline.ref.tag; + } + return undefined; }, - computed: { - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * This field needs a lot of verification, because of different possible cases: - * - * 1. person who is an author of a commit might be a GitLab user - * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar - * 3. If GitLab user does not have avatar he/she might have a Gravatar - * 4. If committer is not a GitLab User he/she can have a Gravatar - * 5. We do not have consistent API object in this case - * 6. We should improve API and the code - * - * @returns {Object|Undefined} - */ - commitAuthor() { - let commitAuthorInformation; - - // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline && - this.pipeline.commit && - this.pipeline.commit.author) { - // 2. if person who is an author of a commit is a GitLab user - // he/she can have a GitLab avatar - if (this.pipeline.commit.author.avatar_url) { - commitAuthorInformation = this.pipeline.commit.author; - - // 3. If GitLab user does not have avatar he/she might have a Gravatar - } else if (this.pipeline.commit.author_gravatar_url) { - commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { - avatar_url: this.pipeline.commit.author_gravatar_url, - }); + /** + * If provided, returns the commit ref. + * Needed to render the commit component column. + * + * Matches `path` prop sent in the API to `ref_url` prop needed + * in the commit component. + * + * @returns {Object|Undefined} + */ + commitRef() { + if (this.pipeline.ref) { + return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { + if (prop === 'path') { + accumulator.ref_url = this.pipeline.ref[prop]; + } else { + accumulator[prop] = this.pipeline.ref[prop]; } - } + return accumulator; + }, {}); + } - // 4. If committer is not a GitLab User he/she can have a Gravatar - if (this.pipeline && - this.pipeline.commit) { - commitAuthorInformation = { - avatar_url: this.pipeline.commit.author_gravatar_url, - web_url: `mailto:${this.pipeline.commit.author_email}`, - username: this.pipeline.commit.author_name, - }; - } + return undefined; + }, - return commitAuthorInformation; - }, - - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitTag() { - if (this.pipeline.ref && - this.pipeline.ref.tag) { - return this.pipeline.ref.tag; - } - return undefined; - }, - - /** - * If provided, returns the commit ref. - * Needed to render the commit component column. - * - * Matches `path` prop sent in the API to `ref_url` prop needed - * in the commit component. - * - * @returns {Object|Undefined} - */ - commitRef() { - if (this.pipeline.ref) { - return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { - if (prop === 'path') { - accumulator.ref_url = this.pipeline.ref[prop]; - } else { - accumulator[prop] = this.pipeline.ref[prop]; - } - return accumulator; - }, {}); - } + /** + * If provided, returns the commit url. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitUrl() { + if (this.pipeline.commit && + this.pipeline.commit.commit_path) { + return this.pipeline.commit.commit_path; + } + return undefined; + }, - return undefined; - }, - - /** - * If provided, returns the commit url. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitUrl() { - if (this.pipeline.commit && - this.pipeline.commit.commit_path) { - return this.pipeline.commit.commit_path; - } - return undefined; - }, - - /** - * If provided, returns the commit short sha. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitShortSha() { - if (this.pipeline.commit && - this.pipeline.commit.short_id) { - return this.pipeline.commit.short_id; - } - return undefined; - }, - - /** - * If provided, returns the commit title. - * Needed to render the commit component column. - * - * @returns {String|Undefined} - */ - commitTitle() { - if (this.pipeline.commit && - this.pipeline.commit.title) { - return this.pipeline.commit.title; - } - return undefined; - }, + /** + * If provided, returns the commit short sha. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitShortSha() { + if (this.pipeline.commit && + this.pipeline.commit.short_id) { + return this.pipeline.commit.short_id; + } + return undefined; }, - template: ` - <tr class="commit"> - <status-scope :pipeline="pipeline"/> - - <pipeline-url :pipeline="pipeline"></pipeline-url> - - <td> - <commit-component - :tag="commitTag" - :commit-ref="commitRef" - :commit-url="commitUrl" - :short-sha="commitShortSha" - :title="commitTitle" - :author="commitAuthor"/> - </td> - - <td class="stage-cell"> - <div class="stage-container dropdown js-mini-pipeline-graph" - v-if="pipeline.details.stages.length > 0" - v-for="stage in pipeline.details.stages"> - <dropdown-stage :stage="stage"/> - </div> - </td> - - <time-ago :pipeline="pipeline"/> - - <pipeline-actions :pipeline="pipeline" /> - </tr> - `, - }); -})(); + /** + * If provided, returns the commit title. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTitle() { + if (this.pipeline.commit && + this.pipeline.commit.title) { + return this.pipeline.commit.title; + } + return undefined; + }, + }, + + template: ` + <tr class="commit"> + <status-scope :pipeline="pipeline"/> + + <pipeline-url :pipeline="pipeline"></pipeline-url> + + <td> + <commit-component + :tag="commitTag" + :commit-ref="commitRef" + :commit-url="commitUrl" + :short-sha="commitShortSha" + :title="commitTitle" + :author="commitAuthor"/> + </td> + + <td class="stage-cell"> + <div class="stage-container dropdown js-mini-pipeline-graph" + v-if="pipeline.details.stages.length > 0" + v-for="stage in pipeline.details.stages"> + <dropdown-stage :stage="stage"/> + </div> + </td> + + <time-ago :pipeline="pipeline"/> + + <td class="pipeline-actions"> + <div class="pull-right btn-group"> + <pipelines-actions-component + v-if="pipeline.details.manual_actions.length" + :actions="pipeline.details.manual_actions" + :service="service" /> + + <pipelines-artifacts-component + v-if="pipeline.details.artifacts.length" + :artifacts="pipeline.details.artifacts" /> + + <async-button-component + v-if="pipeline.flags.retryable" + :service="service" + :endpoint="pipeline.retry_path" + css-class="js-pipelines-retry-button btn-default btn-retry" + title="Retry" + icon="repeat" /> + + <async-button-component + v-if="pipeline.flags.cancelable" + :service="service" + :endpoint="pipeline.cancel_path" + css-class="js-pipelines-cancel-button btn-remove" + title="Cancel" + icon="remove" + confirm-action-message="Are you sure you want to cancel this pipeline?" /> + </div> + </td> + </tr> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.js index 8943b850a72..b9cd28f6249 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.js +++ b/app/assets/javascripts/vue_shared/components/table_pagination.js @@ -1,147 +1,135 @@ -/* global Vue, gl */ -/* eslint-disable no-param-reassign, no-plusplus */ - -window.Vue = require('vue'); - -((gl) => { - const PAGINATION_UI_BUTTON_LIMIT = 4; - const UI_LIMIT = 6; - const SPREAD = '...'; - const PREV = 'Prev'; - const NEXT = 'Next'; - const FIRST = '<< First'; - const LAST = 'Last >>'; - - gl.VueGlPagination = Vue.extend({ - props: { - - // TODO: Consider refactoring in light of turbolinks removal. - - /** - This function will take the information given by the pagination component - - Here is an example `change` method: - - change(pagenum) { - gl.utils.visitUrl(`?page=${pagenum}`); - }, - */ - - change: { - type: Function, - required: true, +const PAGINATION_UI_BUTTON_LIMIT = 4; +const UI_LIMIT = 6; +const SPREAD = '...'; +const PREV = 'Prev'; +const NEXT = 'Next'; +const FIRST = '<< First'; +const LAST = 'Last >>'; + +export default { + props: { + /** + This function will take the information given by the pagination component + + Here is an example `change` method: + + change(pagenum) { + gl.utils.visitUrl(`?page=${pagenum}`); }, + */ + change: { + type: Function, + required: true, + }, - /** - pageInfo will come from the headers of the API call - in the `.then` clause of the VueResource API call - there should be a function that contructs the pageInfo for this component - - This is an example: - - const pageInfo = headers => ({ - perPage: +headers['X-Per-Page'], - page: +headers['X-Page'], - total: +headers['X-Total'], - totalPages: +headers['X-Total-Pages'], - nextPage: +headers['X-Next-Page'], - previousPage: +headers['X-Prev-Page'], - }); - */ - - pageInfo: { - type: Object, - required: true, - }, + /** + pageInfo will come from the headers of the API call + in the `.then` clause of the VueResource API call + there should be a function that contructs the pageInfo for this component + + This is an example: + + const pageInfo = headers => ({ + perPage: +headers['X-Per-Page'], + page: +headers['X-Page'], + total: +headers['X-Total'], + totalPages: +headers['X-Total-Pages'], + nextPage: +headers['X-Next-Page'], + previousPage: +headers['X-Prev-Page'], + }); + */ + pageInfo: { + type: Object, + required: true, }, - methods: { - changePage(e) { - const text = e.target.innerText; - const { totalPages, nextPage, previousPage } = this.pageInfo; - - switch (text) { - case SPREAD: - break; - case LAST: - this.change(totalPages); - break; - case NEXT: - this.change(nextPage); - break; - case PREV: - this.change(previousPage); - break; - case FIRST: - this.change(1); - break; - default: - this.change(+text); - break; - } - }, + }, + methods: { + changePage(e) { + const text = e.target.innerText; + const { totalPages, nextPage, previousPage } = this.pageInfo; + + switch (text) { + case SPREAD: + break; + case LAST: + this.change(totalPages); + break; + case NEXT: + this.change(nextPage); + break; + case PREV: + this.change(previousPage); + break; + case FIRST: + this.change(1); + break; + default: + this.change(+text); + break; + } }, - computed: { - prev() { - return this.pageInfo.previousPage; - }, - next() { - return this.pageInfo.nextPage; - }, - getItems() { - const total = this.pageInfo.totalPages; - const page = this.pageInfo.page; - const items = []; + }, + computed: { + prev() { + return this.pageInfo.previousPage; + }, + next() { + return this.pageInfo.nextPage; + }, + getItems() { + const total = this.pageInfo.totalPages; + const page = this.pageInfo.page; + const items = []; - if (page > 1) items.push({ title: FIRST }); + if (page > 1) items.push({ title: FIRST }); - if (page > 1) { - items.push({ title: PREV, prev: true }); - } else { - items.push({ title: PREV, disabled: true, prev: true }); - } + if (page > 1) { + items.push({ title: PREV, prev: true }); + } else { + items.push({ title: PREV, disabled: true, prev: true }); + } - if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); + if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); - const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); - const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total); + const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); + const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total); - for (let i = start; i <= end; i++) { - const isActive = i === page; - items.push({ title: i, active: isActive, page: true }); - } + for (let i = start; i <= end; i += 1) { + const isActive = i === page; + items.push({ title: i, active: isActive, page: true }); + } - if (total - page > PAGINATION_UI_BUTTON_LIMIT) { - items.push({ title: SPREAD, separator: true, page: true }); - } + if (total - page > PAGINATION_UI_BUTTON_LIMIT) { + items.push({ title: SPREAD, separator: true, page: true }); + } - if (page === total) { - items.push({ title: NEXT, disabled: true, next: true }); - } else if (total - page >= 1) { - items.push({ title: NEXT, next: true }); - } + if (page === total) { + items.push({ title: NEXT, disabled: true, next: true }); + } else if (total - page >= 1) { + items.push({ title: NEXT, next: true }); + } - if (total - page >= 1) items.push({ title: LAST, last: true }); + if (total - page >= 1) items.push({ title: LAST, last: true }); - return items; - }, + return items; }, - template: ` - <div class="gl-pagination"> - <ul class="pagination clearfix"> - <li v-for='item in getItems' - :class='{ - page: item.page, - prev: item.prev, - next: item.next, - separator: item.separator, - active: item.active, - disabled: item.disabled - }' - > - <a @click="changePage($event)">{{item.title}}</a> - </li> - </ul> - </div> - `, - }); -})(window.gl || (window.gl = {})); + }, + template: ` + <div class="gl-pagination"> + <ul class="pagination clearfix"> + <li v-for='item in getItems' + :class='{ + page: item.page, + prev: item.prev, + next: item.next, + separator: item.separator, + active: item.active, + disabled: item.disabled + }' + > + <a @click="changePage($event)">{{item.title}}</a> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js index 4157fefddc9..f1c1e553b16 100644 --- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js @@ -1,11 +1,13 @@ -/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars, -no-param-reassign, no-plusplus */ -/* global Vue */ +/* eslint-disable no-param-reassign, no-plusplus */ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); Vue.http.interceptors.push((request, next) => { Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; - next((response) => { + next(() => { Vue.activeResources--; }); }); diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index f363affa46c..546718ddaf8 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -96,11 +96,9 @@ .award-control { margin: 3px 5px 3px 0; - padding: 5px 6px; + padding: .35em .4em; outline: 0; - line-height: 1; - &.disabled { cursor: default; @@ -140,10 +138,12 @@ } .icon, + gl-emoji, .award-control-icon { - float: left; - margin-right: 5px; - font-size: 18px; + vertical-align: middle; + margin-right: 0.15em; + font-size: 1.5em; + line-height: 1; } .award-control-icon-loading { @@ -154,4 +154,8 @@ color: $border-gray-normal; margin-top: 1px; } + + .award-control-text { + vertical-align: middle; + } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index a4b38723bbd..2c33b235980 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -429,3 +429,9 @@ table { @include str-truncated(100%); } } + +.tooltip { + .tooltip-inner { + word-wrap: break-word; + } +} diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 0a8bc95590e..d86ae57cd9a 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -2,5 +2,6 @@ gl-emoji { display: inline-block; display: inline-flex; vertical-align: middle; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 1.5em; } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index dd2daa4b872..51805c5d734 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -76,12 +76,14 @@ } .input-token { - flex: 1; - -webkit-flex: 1; + max-width: 200px; } - .filtered-search-token + .input-token:not(:last-child) { - max-width: 200px; + .input-token:only-child, + .input-token:last-child { + flex: 1; + -webkit-flex: 1; + max-width: initial; } } @@ -144,7 +146,7 @@ .scroll-container { display: -webkit-flex; display: flex; - overflow-x: scroll; + overflow-x: auto; white-space: nowrap; width: 100%; } @@ -158,8 +160,8 @@ background-color: $white-light; @media (max-width: $screen-xs-min) { - -webkit-flex: 1 1 100%; - flex: 1 1 100%; + -webkit-flex: 1 1 auto; + flex: 1 1 auto; margin-bottom: 10px; .dropdown-menu { @@ -171,17 +173,26 @@ } } + &:hover { + @extend .form-control:hover; + } + + &.focus, + &.focus:hover { + border-color: $dropdown-input-focus-border; + box-shadow: 0 0 4px $search-input-focus-shadow-color; + } + + &.focus .fa-filter { + color: $common-gray-dark; + } + .form-control { position: relative; min-width: 200px; - padding-left: 0; - padding-right: 25px; + padding: 5px 25px 6px 0; border-color: transparent; - &:focus ~ .fa-filter { - color: $common-gray-dark; - } - &:focus, &:hover { outline: none; @@ -221,6 +232,10 @@ .filter-dropdown-container { display: -webkit-flex; display: flex; + + .dropdown-toggle { + line-height: 22px; + } } .dropdown-menu .filter-dropdown-item { @@ -246,7 +261,9 @@ background-color: $white-light; border-top: 0; } +} +@media (max-width: $screen-xs) { .filter-dropdown-container { .dropdown-toggle, .dropdown { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 5d1aba4e529..6660a022260 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -164,11 +164,25 @@ header { } } + .group-name-toggle { + margin: 0 5px; + vertical-align: sub; + } + + .group-title { + &.is-hidden { + .hidable:not(:last-of-type) { + display: none; + } + } + } + .title { position: relative; padding-right: 20px; margin: 0; font-size: 18px; + max-width: 385px; display: inline-block; line-height: $header-height; font-weight: normal; @@ -178,6 +192,14 @@ header { vertical-align: top; white-space: nowrap; + &.initializing { + display: none; + } + + @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { + max-width: 300px; + } + @media (max-width: $screen-xs-max) { max-width: 190px; } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index ea45aaa0253..205d23b1329 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -138,7 +138,6 @@ .nav-links { display: inline-block; - width: 50%; margin-bottom: 0; border-bottom: none; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index db5e2c51fe7..c241816788b 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -306,6 +306,11 @@ a > code { * Textareas intended for GFM * */ +textarea.js-gfm-input { + font-family: $monospace_font; + font-size: 13px; +} + .strikethrough { text-decoration: line-through; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 9a36d76136b..f9ee33019cd 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -420,12 +420,9 @@ display: -webkit-flex; display: flex; - .form-control { - margin-left: auto; - - @media (min-width: $screen-sm-min) { - max-width: 200px; - } + .issues-filters { + -webkit-flex: 1; + flex: 1; } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 2029b6893ef..da8410eca66 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -38,6 +38,38 @@ } } +.pipeline-info { + .status-icon-container { + display: inline-block; + vertical-align: middle; + margin-right: 3px; + + svg { + display: block; + width: 22px; + height: 22px; + } + } + + .mr-widget-pipeline-graph { + display: inline-block; + vertical-align: middle; + margin: 0 -6px 0 0; + + .dropdown-menu { + margin-top: 11px; + } + } +} + +.branch-info .commit-icon { + margin-right: 3px; + + svg { + top: 3px; + } +} + /* * Commit message textarea for web editor and * custom merge request message diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 5b777953fb0..ad3dbc7ac48 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -369,13 +369,11 @@ // Custom CSS for components .item-conmmit-component { .commit-icon { - position: relative; - top: 3px; - left: 1px; - display: inline-block; - svg { - float: left; + display: inline-block; + width: 20px; + height: 20px; + vertical-align: bottom; } } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index b595480561b..cb7ebd61504 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -56,7 +56,10 @@ ul.related-merge-requests > li { .merge-request-id { display: inline-block; - width: 3em; +} + +.merge-request-info { + margin-left: 5px; } .merge-request-status { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index c2156a5ac69..927bf9805ce 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -148,6 +148,18 @@ .error-alert > .alert { margin-top: 5px; margin-bottom: 5px; + + &.alert-dismissable { + .close { + color: $white-light; + opacity: 0.85; + font-weight: normal; + + &:hover { + opacity: 1; + } + } + } } .discussion-body, diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 20eabc83142..33b38ca6923 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -72,11 +72,6 @@ color: $gl-text-color-secondary; font-size: 14px; } - - svg, - .fa { - margin-right: 0; - } } .btn-group { @@ -921,3 +916,22 @@ } } } + +/** + * Play button with icon in dropdowns + */ +.ci-table .no-btn { + border: none; + background: none; + outline: none; + width: 100%; + text-align: left; + + .icon-play { + position: relative; + top: 2px; + margin-right: 5px; + height: 13px; + width: 12px; + } +} diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 2992568ae66..a8c0937569c 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -37,7 +37,6 @@ module ServiceParams :namespace, :new_issue_url, :notify, - :notify_only_broken_builds, :notify_only_broken_pipelines, :password, :priority, diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 325ae565537..be00d765f73 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -42,7 +42,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController private def load_projects(base_scope) - projects = base_scope.sorted_by_activity.includes(:namespace) + projects = base_scope.sorted_by_activity.includes(:route, namespace: :route) filter_projects(projects) end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 5848ca62777..096de8032ae 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -22,12 +22,12 @@ class Dashboard::TodosController < Dashboard::ApplicationController end def destroy_all - TodoService.new.mark_todos_as_done(@todos, current_user) + updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user) respond_to do |format| format.html { redirect_to dashboard_todos_path, notice: 'All todos were marked as done.' } format.js { head :ok } - format.json { render json: todos_counts } + format.json { render json: todos_counts.merge(updated_ids: updated_ids) } end end @@ -37,6 +37,12 @@ class Dashboard::TodosController < Dashboard::ApplicationController render json: todos_counts end + def bulk_restore + TodoService.new.mark_todos_as_pending_by_ids(params[:ids], current_user) + + render json: todos_counts + end + # Used in TodosHelper also def self.todos_count_format(count) count >= 100 ? '99+' : count @@ -45,7 +51,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController private def find_todos - @todos ||= TodosFinder.new(current_user, params).execute + @todos ||= TodosFinder.new(current_user, params.merge(include_associations: true)).execute end def todos_counts diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 26e17a7553e..6167f9bd335 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -2,7 +2,7 @@ class Explore::ProjectsController < Explore::ApplicationController include FilterProjects def index - @projects = ProjectsFinder.new.execute(current_user) + @projects = load_projects @tags = @projects.tags_on(:tags) @projects = @projects.tagged_with(params[:tag]) if params[:tag].present? @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present? @@ -21,7 +21,8 @@ class Explore::ProjectsController < Explore::ApplicationController end def trending - @projects = filter_projects(Project.trending) + @projects = load_projects(Project.trending) + @projects = filter_projects(@projects) @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]) @@ -36,7 +37,7 @@ class Explore::ProjectsController < Explore::ApplicationController end def starred - @projects = ProjectsFinder.new.execute(current_user) + @projects = load_projects @projects = filter_projects(@projects) @projects = @projects.reorder('star_count DESC') @projects = @projects.page(params[:page]) @@ -50,4 +51,11 @@ class Explore::ProjectsController < Explore::ApplicationController end end end + + protected + + def load_projects(base_scope = nil) + base_scope ||= ProjectsFinder.new.execute(current_user) + base_scope.includes(:route, namespace: :route) + end end diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index a271e2dfc4b..b8b71d295f6 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -17,6 +17,6 @@ class Profiles::NotificationsController < Profiles::ApplicationController end def user_params - params.require(:user).permit(:notification_email, :notified_of_own_activity) + params.require(:user).permit(:notification_email) end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index e2f81b09adc..f1a93ccb3ad 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -89,4 +89,9 @@ class Projects::ApplicationController < ApplicationController def builds_enabled return render_404 unless @project.feature_available?(:builds, current_user) end + + def update_ref + branch_exists = @repository.find_branch(@target_branch) + @ref = @target_branch if branch_exists + end end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 863a766a255..6461eeac11c 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -8,9 +8,12 @@ class Projects::BlameController < Projects::ApplicationController def show @blob = @repository.blob_at(@commit.id, @path) - + return render_404 unless @blob + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + @blame_groups = Gitlab::Blame.new(@blob, @commit).groups end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 52fc67d162c..80a95c6158b 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -89,11 +89,6 @@ class Projects::BlobController < Projects::ApplicationController private - def update_ref - branch_exists = @repository.find_branch(@target_branch) - @ref = @target_branch if branch_exists - end - def blob @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path)) diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 22714d9c5a4..840405f38cb 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -11,16 +11,18 @@ class Projects::BranchesController < Projects::ApplicationController @sort = params[:sort].presence || sort_value_name @branches = BranchesFinder.new(@repository, params).execute - @branches = Kaminari.paginate_array(@branches).page(params[:page]) unless params[:show_all].present? - respond_to do |format| format.html do + paginate_branches + @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name)) + @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max end end format.json do + paginate_branches unless params[:show_all] render json: @branches.map(&:name) end end @@ -91,6 +93,10 @@ class Projects::BranchesController < Projects::ApplicationController end end + def paginate_branches + @branches = Kaminari.paginate_array(@branches).page(params[:page]) + end + def url_to_autodeploy_setup(project, branch_name) namespace_project_new_blob_path( project.namespace, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cfd2c46fbd8..7f81280673b 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -6,6 +6,8 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableCollections include SpammableActions + prepend_before_action :authenticate_user!, only: [:new] + before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, @@ -146,7 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController end format.json do - render json: @issue.to_json(include: { milestone: {}, assignee: { methods: :avatar_url }, labels: { methods: :text_color } }, methods: [:task_status, :task_status_short]) + render json: @issue.to_json(include: { milestone: {}, assignee: { only: [:name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }, methods: [:task_status, :task_status_short]) end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 82f9b6e06db..677a8a1a73a 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -308,7 +308,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end format.json do - render json: @merge_request.to_json(include: { milestone: {}, assignee: { methods: :avatar_url }, labels: { methods: :text_color } }, methods: [:task_status, :task_status_short]) + render json: @merge_request.to_json(include: { milestone: {}, assignee: { only: [:name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }, methods: [:task_status, :task_status_short]) end end rescue ActiveRecord::StaleObjectError diff --git a/app/controllers/projects/settings/members_controller.rb b/app/controllers/projects/settings/members_controller.rb index cbfa2afa959..54f9dceddef 100644 --- a/app/controllers/projects/settings/members_controller.rb +++ b/app/controllers/projects/settings/members_controller.rb @@ -9,6 +9,7 @@ module Projects @skip_groups = @group_links.pluck(:group_id) @skip_groups << @project.namespace_id unless @project.personal? + @skip_groups += @project.group.ancestors.pluck(:id) if @project.group @project_members = MembersFinder.new(@project, current_user).execute diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index ea7e4d9f663..e13f0bde315 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -14,7 +14,9 @@ class Projects::TagsController < Projects::ApplicationController @tags = TagsFinder.new(@repository, params).execute @tags = Kaminari.paginate_array(@tags).page(params[:page]) - @releases = project.releases.where(tag: @tags.map(&:name)) + tag_names = @tags.map(&:name) + @tags_pipelines = @project.pipelines.latest_successful_for_refs(tag_names) + @releases = project.releases.where(tag: tag_names) end def show diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 4f094146348..637b61504d8 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -34,6 +34,7 @@ class Projects::TreeController < Projects::ApplicationController def create_dir return render_404 unless @commit_params.values.all? + update_ref create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.", success_path: namespace_project_tree_path(@project.namespace, @project, File.join(@target_branch, @dir_name)), failure_path: namespace_project_tree_path(@project.namespace, @project, @ref)) diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 8b6c83d4fed..f210f7e61d2 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -45,8 +45,9 @@ class Projects::WikisController < Projects::ApplicationController return render('empty') unless can?(current_user, :create_wiki, @project) @page = @project_wiki.find_page(params[:id]) + @page = WikiPages::UpdateService.new(@project, current_user, wiki_params).execute(@page) - if @page = WikiPages::UpdateService.new(@project, current_user, wiki_params).execute(@page) + if @page.valid? redirect_to( namespace_project_wiki_path(@project.namespace, @project, @page), notice: 'Wiki was successfully updated.' diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 3e2015b7d5e..47f7e0b1b28 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -117,7 +117,7 @@ class ProjectsController < Projects::ApplicationController return access_denied! unless can?(current_user, :remove_project, @project) ::Projects::DestroyService.new(@project, current_user, {}).async_execute - flash[:alert] = "Project '#{@project.name}' will be deleted." + flash[:alert] = "Project '#{@project.name_with_namespace}' will be deleted." redirect_to dashboard_projects_path rescue Projects::DestroyService::DestroyError => ex @@ -267,8 +267,9 @@ class ProjectsController < Projects::ApplicationController @project_wiki = @project.wiki @wiki_home = @project_wiki.find_page('home', params[:version_id]) elsif @project.feature_available?(:issues, current_user) - @issues = issues_collection - @issues = @issues.page(params[:page]) + @issues = issues_collection.page(params[:page]) + @collection_type = 'Issue' + @issuable_meta_data = issuable_meta_data(@issues, @collection_type) end render :show @@ -315,6 +316,7 @@ class ProjectsController < Projects::ApplicationController :namespace_id, :only_allow_merge_if_all_discussions_are_resolved, :only_allow_merge_if_pipeline_succeeds, + :printing_merge_request_link_enabled, :path, :public_builds, :request_access_enabled, diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 2fca012252e..f7ebb1807d7 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -310,6 +310,10 @@ class IssuableFinder params[:milestone_title] == Milestone::Upcoming.name end + def filter_by_started_milestone? + params[:milestone_title] == Milestone::Started.name + end + def by_milestone(items) if milestones? if filter_by_no_milestone? @@ -317,6 +321,8 @@ class IssuableFinder elsif filter_by_upcoming_milestone? upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items)) items = items.left_joins_milestones.where(milestone_id: upcoming_ids) + elsif filter_by_started_milestone? + items = items.left_joins_milestones.where('milestones.start_date <= NOW()') else items = items.with_milestone(params[:milestone_title]) items_projects = projects(items) diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index b7f091f334d..13d33a1c31b 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -24,6 +24,7 @@ class TodosFinder def execute items = current_user.todos + items = include_associations(items) items = by_action_id(items) items = by_action(items) items = by_author(items) @@ -38,6 +39,17 @@ class TodosFinder private + def include_associations(items) + return items unless params[:include_associations] + + items.includes( + [ + target: { project: [:route, namespace: :route] }, + author: { namespace: :route }, + ] + ) + end + def action_id? action_id.present? && Todo::ACTION_NAMES.has_key?(action_id.to_i) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 7f32c1b5300..8631bc54509 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -203,4 +203,18 @@ module BlobHelper 'blob-language' => @blob && @blob.language.try(:ace_mode) } end + + def copy_file_path_button(file_path) + clipboard_button(clipboard_text: file_path, class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') + end + + def copy_blob_content_button(blob) + return if markup?(blob.name) + + clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard") + end + + def open_raw_file_button(path) + link_to icon('file-code-o'), path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' } + end end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index a7cdca9ba2e..2de9e0de310 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -59,6 +59,24 @@ module CiStatusHelper custom_icon(icon_name) end + def pipeline_status_cache_key(pipeline_status) + "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" + end + + def render_project_pipeline_status(pipeline_status, tooltip_placement: 'auto left') + project = pipeline_status.project + path = pipelines_namespace_project_commit_path( + project.namespace, + project, + pipeline_status.sha) + + render_status_with_link( + 'commit', + pipeline_status.status, + path, + tooltip_placement: tooltip_placement) + end + def render_commit_status(commit, ref: nil, tooltip_placement: 'auto left') project = commit.project path = pipelines_namespace_project_commit_path( diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 8aad39e148b..cef624430da 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -211,7 +211,7 @@ module CommitsHelper external_url = environment.external_url_for(diff_new_path, commit_sha) return unless external_url - link_to(external_url, class: 'btn btn-file-option has-tooltip', target: '_blank', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do + link_to(external_url, class: 'btn btn-file-option has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do icon('external-link') end end diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index 6226cfe25cf..cd442237086 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -172,7 +172,9 @@ module GitlabMarkdownHelper # text hasn't already been truncated, then append "..." to the node contents # and return true. Otherwise return false. def truncate_if_block(node, truncated) - if node.element? && node.description&.block? && !truncated + return true if truncated + + if node.element? && (node.description&.block? || node.matches?('pre > code > .line')) node.inner_html = "#{node.inner_html}..." if node.next_sibling true else diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 926c9703628..a6014088e92 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -12,17 +12,18 @@ module GroupsHelper end def group_title(group, name = nil, url = nil) + @has_group_title = true full_title = '' group.ancestors.each do |parent| - full_title += link_to(simple_sanitize(parent.name), group_path(parent)) - full_title += ' / '.html_safe + full_title += link_to(simple_sanitize(parent.name), group_path(parent), class: 'group-path hidable') + full_title += '<span class="hidable"> / </span>'.html_safe end - full_title += link_to(simple_sanitize(group.name), group_path(group)) - full_title += ' · '.html_safe + link_to(simple_sanitize(name), url) if name + full_title += link_to(simple_sanitize(group.name), group_path(group), class: 'group-path') + full_title += ' · '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path') if name - content_tag :span do + content_tag :span, class: 'group-title' do full_title.html_safe end end diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index a0642a1894b..a57b5a8fea5 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -7,7 +7,7 @@ module ImportHelper def provider_project_link(provider, path_with_namespace) url = __send__("#{provider}_project_url", path_with_namespace) - link_to path_with_namespace, url, target: '_blank' + link_to path_with_namespace, url, target: '_blank', rel: 'noopener noreferrer' end private diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index aad83731b87..a777db2826b 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -90,11 +90,14 @@ module IssuablesHelper end def milestone_dropdown_label(milestone_title, default_label = "Milestone") - if milestone_title == Milestone::Upcoming.name - milestone_title = Milestone::Upcoming.title - end + title = + case milestone_title + when Milestone::Upcoming.name then Milestone::Upcoming.title + when Milestone::Started.name then Milestone::Started.title + else milestone_title.presence + end - h(milestone_title.presence || default_label) + h(title || default_label) end def to_url_reference(issuable) diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 7011e670cee..5053b937c02 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -82,12 +82,13 @@ module MilestonesHelper def milestone_remaining_days(milestone) if milestone.expired? content_tag(:strong, 'Past due') - elsif milestone.due_date - days = milestone.remaining_days - content = content_tag(:strong, days) - content << " #{'day'.pluralize(days)} remaining" elsif milestone.upcoming? content_tag(:strong, 'Upcoming') + elsif milestone.due_date + time_ago = time_ago_in_words(milestone.due_date) + content = time_ago.gsub(/\d+/) { |match| "<strong>#{match}</strong>" } + content.slice!("about ") + content << " remaining" elsif milestone.start_date && milestone.start_date.past? days = milestone.elapsed_days content = content_tag(:strong, days) diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index c1523b4dabf..a8f167cbff2 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -16,6 +16,7 @@ module NavHelper "page-gutter build-sidebar right-sidebar-expanded" elsif current_path?('wikis#show') || current_path?('wikis#edit') || + current_path?('wikis#update') || current_path?('wikis#history') || current_path?('wikis#git_access') "page-gutter wiki-sidebar right-sidebar-expanded" diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 4befeacc135..bd0c2cd661e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -159,6 +159,13 @@ module ProjectsHelper choose a GitLab CI Yaml template and commit your changes. #{link_to_autodeploy_doc}".html_safe end + def project_list_cache_key(project) + key = [project.namespace.cache_key, project.cache_key, controller.controller_name, controller.action_name, current_application_settings.cache_key, 'v2.3'] + key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status? + + key + end + private def repo_children_classes(field) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 18734f1411f..959ee310867 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -16,7 +16,8 @@ module SortingHelper sort_value_oldest_signin => sort_title_oldest_signin, sort_value_downvotes => sort_title_downvotes, sort_value_upvotes => sort_title_upvotes, - sort_value_priority => sort_title_priority + sort_value_priority => sort_title_priority, + sort_value_label_priority => sort_title_label_priority } end @@ -50,6 +51,10 @@ module SortingHelper end def sort_title_priority + 'Priority' + end + + def sort_title_label_priority 'Label priority' end @@ -161,6 +166,10 @@ module SortingHelper 'priority' end + def sort_value_label_priority + 'label_priority' + end + def sort_value_oldest_updated 'updated_asc' end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 7f8efb0a4ac..847a8fdfca6 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -39,9 +39,13 @@ module TodosHelper namespace_project_commit_path(todo.project.namespace.becomes(Namespace), todo.project, todo.target, anchor: anchor) else - path = [todo.project.namespace.becomes(Namespace), todo.project, todo.target] - - path.unshift(:pipelines) if todo.build_failed? + if todo.build_failed? + # associated namespace and route would be loaded from the db again if todo.project was used + project = todo.target.project + path = [:pipelines, project.namespace.becomes(Namespace), project, todo.target] + else + path = [todo.target] + end polymorphic_path(path, anchor: anchor) end @@ -99,8 +103,7 @@ module TodosHelper end def todo_projects_options - projects = current_user.authorized_projects.sorted_by_activity.non_archived - projects = projects.includes(:namespace) + projects = current_user.authorized_projects.sorted_by_activity.non_archived.with_route projects = projects.map do |project| { id: project.id, text: project.name_with_namespace } diff --git a/app/mailers/emails/builds.rb b/app/mailers/emails/builds.rb deleted file mode 100644 index 3853af6201a..00000000000 --- a/app/mailers/emails/builds.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Emails - module Builds - def build_fail_email(build_id, to) - @build = Ci::Build.find(build_id) - @project = @build.project - - add_project_headers - add_build_headers('failed') - - mail(to: to, subject: subject("Build failed for #{@project.name}", @build.short_sha)) - end - - def build_success_email(build_id, to) - @build = Ci::Build.find(build_id) - @project = @build.project - - add_project_headers - add_build_headers('success') - mail(to: to, subject: subject("Build success for #{@project.name}", @build.short_sha)) - end - - private - - def add_build_headers(status) - headers['X-GitLab-Build-Id'] = @build.id - headers['X-GitLab-Build-Ref'] = @build.ref - headers['X-GitLab-Build-Status'] = status.to_s - end - end -end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 5b9226a6b81..14df6f8f0a3 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -6,7 +6,6 @@ class Notify < BaseMailer include Emails::Notes include Emails::Projects include Emails::Profile - include Emails::Builds include Emails::Pipelines include Emails::Members diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index be632930895..671a0fe98cc 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -163,6 +163,8 @@ class ApplicationSetting < ActiveRecord::Base end def self.current + ensure_cache_setup + Rails.cache.fetch(CACHE_KEY) do ApplicationSetting.last end @@ -176,9 +178,16 @@ class ApplicationSetting < ActiveRecord::Base end def self.cached + ensure_cache_setup Rails.cache.fetch(CACHE_KEY) end + def self.ensure_cache_setup + # This is a workaround for a Rails bug that causes attribute methods not + # to be loaded when read from cache: https://github.com/rails/rails/issues/27348 + ApplicationSetting.define_attribute_methods + end + def self.defaults_ce { after_sign_up_text: nil, diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3722047251d..ad0be70c32a 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -15,7 +15,7 @@ module Ci def persisted_environment @persisted_environment ||= Environment.find_by( name: expanded_environment_name, - project_id: gl_project_id + project: project ) end @@ -223,7 +223,8 @@ module Ci def merge_request merge_requests = MergeRequest.includes(:merge_request_diff) - .where(source_branch: ref, source_project_id: pipeline.gl_project_id) + .where(source_branch: ref, + source_project: pipeline.project) .reorder(iid: :asc) merge_requests.find do |merge_request| @@ -231,10 +232,6 @@ module Ci end end - def project_id - gl_project_id - end - def repo_url auth = "gitlab-ci-token:#{ensure_token!}@" project.http_url_to_repo.sub(/^https?:\/\//) do |prefix| @@ -542,6 +539,16 @@ module Ci Gitlab::Ci::Build::Credentials::Factory.new(self).create! end + def dependencies + depended_jobs = depends_on_builds + + return depended_jobs unless options[:dependencies].present? + + depended_jobs.select do |job| + options[:dependencies].include?(job.name) + end + end + private def update_artifacts_size @@ -561,7 +568,7 @@ module Ci end def unscoped_project - @unscoped_project ||= Project.unscoped.find_by(id: gl_project_id) + @unscoped_project ||= Project.unscoped.find_by(id: project_id) end CI_REGISTRY_USER = 'gitlab-ci-token'.freeze diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8a5a9aa4adb..f12be98c80c 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -5,9 +5,7 @@ module Ci include Importable include AfterCommitQueue - self.table_name = 'ci_commits' - - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project belongs_to :user has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id @@ -22,6 +20,7 @@ module Ci validate :valid_commit_sha, unless: :importing? after_create :keep_around_commits, unless: :importing? + after_create :refresh_build_status_cache state_machine :status, initial: :created do event :enqueue do @@ -114,6 +113,12 @@ module Ci success.latest(ref).order(id: :desc).first end + def self.latest_successful_for_refs(refs) + success.latest(refs).order(id: :desc).each_with_object({}) do |pipeline, hash| + hash[pipeline.ref] ||= pipeline + end + end + def self.truncate_sha(sha) sha[0...8] end @@ -328,6 +333,7 @@ module Ci when 'manual' then block end end + refresh_build_status_cache end def predefined_variables @@ -369,6 +375,10 @@ module Ci .fabricate! end + def refresh_build_status_cache + Ci::PipelineStatus.new(project, sha: sha, status: status).store_in_cache_if_needed + end + private def pipeline_data diff --git a/app/models/ci/pipeline_status.rb b/app/models/ci/pipeline_status.rb new file mode 100644 index 00000000000..048047d0e34 --- /dev/null +++ b/app/models/ci/pipeline_status.rb @@ -0,0 +1,86 @@ +# This class is not backed by a table in the main database. +# It loads the latest Pipeline for the HEAD of a repository, and caches that +# in Redis. +module Ci + class PipelineStatus + attr_accessor :sha, :status, :project, :loaded + + delegate :commit, to: :project + + def self.load_for_project(project) + new(project).tap do |status| + status.load_status + end + end + + def initialize(project, sha: nil, status: nil) + @project = project + @sha = sha + @status = status + end + + def has_status? + loaded? && sha.present? && status.present? + end + + def load_status + return if loaded? + + if has_cache? + load_from_cache + else + load_from_commit + store_in_cache + end + + self.loaded = true + end + + def load_from_commit + return unless commit + + self.sha = commit.sha + self.status = commit.status + end + + # We only cache the status for the HEAD commit of a project + # This status is rendered in project lists + def store_in_cache_if_needed + return unless sha + return delete_from_cache unless commit + store_in_cache if commit.sha == self.sha + end + + def load_from_cache + Gitlab::Redis.with do |redis| + self.sha, self.status = redis.hmget(cache_key, :sha, :status) + end + end + + def store_in_cache + Gitlab::Redis.with do |redis| + redis.mapped_hmset(cache_key, { sha: sha, status: status }) + end + end + + def delete_from_cache + Gitlab::Redis.with do |redis| + redis.del(cache_key) + end + end + + def has_cache? + Gitlab::Redis.with do |redis| + redis.exists(cache_key) + end + end + + def loaded? + self.loaded + end + + def cache_key + "projects/#{project.id}/build_status" + end + end +end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index edd21f984c8..487ba61bc9c 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -9,7 +9,7 @@ module Ci has_many :builds has_many :runner_projects, dependent: :destroy - has_many :projects, through: :runner_projects, foreign_key: :gl_project_id + has_many :projects, through: :runner_projects has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' @@ -24,7 +24,7 @@ module Ci scope :owned_or_shared, ->(project_id) do joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id') - .where("ci_runner_projects.gl_project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) + .where("ci_runner_projects.project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) end scope :assignable_for, ->(project) do diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index 234376a7e4c..5f01a0daae9 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -1,10 +1,10 @@ module Ci class RunnerProject < ActiveRecord::Base extend Ci::Model - + belongs_to :runner - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project - validates :runner_id, uniqueness: { scope: :gl_project_id } + validates :runner_id, uniqueness: { scope: :project_id } end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 90473d41c04..cba1d81a861 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -4,7 +4,7 @@ module Ci acts_as_paranoid - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project belongs_to :owner, class_name: "User" has_many :trigger_requests, dependent: :destroy diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 2c8698d8b5d..6c6586110c5 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -2,11 +2,11 @@ module Ci class Variable < ActiveRecord::Base extend Ci::Model - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project validates :key, presence: true, - uniqueness: { scope: :gl_project_id }, + uniqueness: { scope: :project_id }, length: { maximum: 255 }, format: { with: /\A[a-zA-Z0-9_]+\z/, message: "can contain only letters, digits and '_'." } diff --git a/app/models/commit.rb b/app/models/commit.rb index 0a18986ef26..ce92cc369ad 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -231,6 +231,10 @@ class Commit project.pipelines.where(sha: sha) end + def latest_pipeline + pipelines.last + end + def status(ref = nil) @statuses ||= {} @@ -317,7 +321,14 @@ class Commit end def raw_diffs(*args) - raw.diffs(*args) + use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] + + if use_gitaly && !deltas_only + Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + else + raw.diffs(*args) + end end def diffs(diff_options = nil) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 7e23e14794f..8c71267da65 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -5,7 +5,7 @@ class CommitStatus < ActiveRecord::Base self.table_name = 'ci_builds' - belongs_to :project, foreign_key: :gl_project_id + belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id belongs_to :user @@ -133,6 +133,12 @@ class CommitStatus < ActiveRecord::Base false end + # Added in 9.0 to keep backward compatibility for projects exported in 8.17 + # and prior. + def gl_project_id + 'dummy' + end + def detailed_status(current_user) Gitlab::Ci::Status::Factory .new(self, current_user) diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index 5101cc7e687..0a1a65da05a 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -31,6 +31,7 @@ module HasStatus WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' WHEN (#{running})+(#{pending})>0 THEN 'running' WHEN (#{manual})>0 THEN 'manual' + WHEN (#{created})>0 THEN 'running' ELSE 'failed' END)" end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3cf4c67d7e7..e7bd20b322a 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -48,11 +48,13 @@ module Issuable delegate :name, :email, + :public_email, to: :author, prefix: true delegate :name, :email, + :public_email, to: :assignee, allow_nil: true, prefix: true @@ -144,7 +146,8 @@ module Issuable when 'milestone_due_desc' then order_milestone_due_desc when 'downvotes_desc' then order_downvotes_desc when 'upvotes_desc' then order_upvotes_desc - when 'priority' then order_labels_priority(excluded_labels: excluded_labels) + when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) + when 'priority' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) when 'position_asc' then order_position_asc else order_by(method) @@ -154,7 +157,28 @@ module Issuable sorted.order(id: :desc) end - def order_labels_priority(excluded_labels: []) + def order_due_date_and_labels_priority(excluded_labels: []) + # The order_ methods also modify the query in other ways: + # + # - For milestones, we add a JOIN. + # - For label priority, we change the SELECT, and add a GROUP BY.# + # + # After doing those, we need to reorder to the order we want. The existing + # ORDER BYs won't work because: + # + # 1. We need milestone due date first. + # 2. We can't ORDER BY a column that isn't in the GROUP BY and doesn't + # have an aggregate function applied, so we do a useless MIN() instead. + # + milestones_due_date = 'MIN(milestones.due_date)' + + order_milestone_due_asc. + order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date]). + reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'), + Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) + end + + def order_labels_priority(excluded_labels: [], extra_select_columns: []) params = { target_type: name, target_column: "#{table_name}.id", @@ -164,7 +188,12 @@ module Issuable highest_priority = highest_label_priority(params).to_sql - select("#{table_name}.*, (#{highest_priority}) AS highest_priority"). + select_columns = [ + "#{table_name}.*", + "(#{highest_priority}) AS highest_priority" + ] + extra_select_columns + + select(select_columns.join(', ')). group(arel_table[:id]). reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')) end @@ -234,6 +263,7 @@ module Issuable user: user.hook_attrs, project: project.hook_attrs, object_attributes: hook_attrs, + labels: labels.map(&:hook_attrs), # DEPRECATED repository: project.hook_attrs.slice(:name, :url, :description, :homepage) } diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 9f6d215ceb3..529fb5ce988 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -51,11 +51,13 @@ module Routable paths.each do |path| path = connection.quote(path) - where = "(routes.path = #{path})" - if cast_lower - where = "(#{where} OR (LOWER(routes.path) = LOWER(#{path})))" - end + where = + if cast_lower + "(LOWER(routes.path) = LOWER(#{path}))" + else + "(routes.path = #{path})" + end wheres << where end diff --git a/app/models/event.rb b/app/models/event.rb index d7ca8e3c599..5c34844b5d3 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -16,7 +16,7 @@ class Event < ActiveRecord::Base RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour - delegate :name, :email, to: :author, prefix: true, allow_nil: true + delegate :name, :email, :public_email, to: :author, prefix: true, allow_nil: true delegate :title, to: :issue, prefix: true, allow_nil: true delegate :title, to: :merge_request, prefix: true, allow_nil: true delegate :title, to: :note, prefix: true, allow_nil: true diff --git a/app/models/group.rb b/app/models/group.rb index bd0ecae3da4..60274386103 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -207,7 +207,7 @@ class Group < Namespace end def members_with_parents - GroupMember.non_request.where(source_id: ancestors.map(&:id).push(id)) + GroupMember.non_request.where(source_id: ancestors.pluck(:id).push(id)) end def users_with_parents diff --git a/app/models/issue.rb b/app/models/issue.rb index f188dcb673c..c2b261825b2 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -57,10 +57,24 @@ class Issue < ActiveRecord::Base state :opened state :reopened state :closed + + before_transition any => :closed do |issue| + issue.closed_at = Time.zone.now + end + + before_transition closed: any do |issue| + issue.closed_at = nil + end end def hook_attrs - attributes + attrs = { + total_time_spent: total_time_spent, + human_total_time_spent: human_total_time_spent, + human_time_estimate: human_time_estimate + } + + attributes.merge!(attrs) end def self.reference_prefix diff --git a/app/models/label.rb b/app/models/label.rb index f68a8c9cff2..568fa6d44f5 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -169,6 +169,10 @@ class Label < ActiveRecord::Base end end + def hook_attrs + attributes + end + private def issues_count(user, params = {}) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 0f7b8311588..cef8ad76b07 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -7,6 +7,7 @@ class MergeRequest < ActiveRecord::Base belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" + belongs_to :project, foreign_key: :target_project_id belongs_to :merge_user, class_name: "User" has_many :merge_request_diffs, dependent: :destroy @@ -523,7 +524,10 @@ class MergeRequest < ActiveRecord::Base source: source_project.try(:hook_attrs), target: target_project.hook_attrs, last_commit: nil, - work_in_progress: work_in_progress? + work_in_progress: work_in_progress?, + total_time_spent: total_time_spent, + human_total_time_spent: human_total_time_spent, + human_time_estimate: human_time_estimate } if diff_head_commit @@ -537,10 +541,6 @@ class MergeRequest < ActiveRecord::Base target_project != source_project end - def project - target_project - end - # If the merge request closes any issues, save this information in the # `MergeRequestsClosingIssues` model. This is a performance optimization. # Calculating this information for a number of merge requests requires diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 7331000a9f2..c0deb59ec4c 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -5,6 +5,7 @@ class Milestone < ActiveRecord::Base None = MilestoneStruct.new('No Milestone', 'No Milestone', 0) Any = MilestoneStruct.new('Any Milestone', '', -1) Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2) + Started = MilestoneStruct.new('Started', '#started', -3) include CacheMarkdownField include InternalId diff --git a/app/models/project.rb b/app/models/project.rb index 8c2dadf4659..da4704554b3 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -89,7 +89,6 @@ class Project < ActiveRecord::Base has_one :campfire_service, dependent: :destroy has_one :drone_ci_service, dependent: :destroy has_one :emails_on_push_service, dependent: :destroy - has_one :builds_email_service, dependent: :destroy has_one :pipelines_email_service, dependent: :destroy has_one :irker_service, dependent: :destroy has_one :pivotaltracker_service, dependent: :destroy @@ -159,13 +158,13 @@ class Project < ActiveRecord::Base has_one :project_feature, dependent: :destroy has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete - has_many :commit_statuses, dependent: :destroy, foreign_key: :gl_project_id - has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline', foreign_key: :gl_project_id - has_many :builds, class_name: 'Ci::Build', foreign_key: :gl_project_id # the builds are created from the commit_statuses - has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject', foreign_key: :gl_project_id + has_many :commit_statuses, dependent: :destroy + has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline' + has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses + has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_many :variables, dependent: :destroy, class_name: 'Ci::Variable', foreign_key: :gl_project_id - has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :gl_project_id + has_many :variables, dependent: :destroy, class_name: 'Ci::Variable' + has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger' has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy @@ -197,6 +196,7 @@ class Project < ActiveRecord::Base validates :name, uniqueness: { scope: :namespace_id } validates :path, uniqueness: { scope: :namespace_id } validates :import_url, addressable_url: true, if: :external_import? + 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 :avatar_type, @@ -881,13 +881,9 @@ class Project < ActiveRecord::Base end def http_url_to_repo(user = nil) - url = web_url + credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) - if user - url.sub!(%r{\Ahttps?://}) { |protocol| "#{protocol}#{user.username}@" } - end - - "#{url}.git" + Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url end # Check if current branch name is marked as protected in the system @@ -1209,6 +1205,10 @@ class Project < ActiveRecord::Base end end + def pipeline_status + @pipeline_status ||= Ci::PipelineStatus.load_for_project(self) + end + def mark_import_as_failed(error_message) original_errors = errors.dup sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message) diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb index ebd21e37189..0c526b53d72 100644 --- a/app/models/project_services/builds_email_service.rb +++ b/app/models/project_services/builds_email_service.rb @@ -1,107 +1,11 @@ +# This class is to be removed with 9.1 +# We should also by then remove BuildsEmailService from database class BuildsEmailService < Service - prop_accessor :recipients - boolean_accessor :add_pusher - boolean_accessor :notify_only_broken_builds - validates :recipients, presence: true, if: ->(s) { s.activated? && !s.add_pusher? } - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_builds = true - end - end - - def title - 'Builds emails' - end - - def description - 'Email the builds status to a list of recipients.' - end - def self.to_param 'builds_email' end def self.supported_events - %w(build) - end - - def execute(push_data) - return unless supported_events.include?(push_data[:object_kind]) - return unless should_build_be_notified?(push_data) - - recipients = all_recipients(push_data) - - if recipients.any? - BuildEmailWorker.perform_async( - push_data[:build_id], - recipients, - push_data - ) - end - end - - def can_test? - project.builds.any? - end - - def disabled_title - "Please setup a build on your repository." - end - - def test_data(project = nil, user = nil) - Gitlab::DataBuilder::Build.build(project.builds.last) - end - - def fields - [ - { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by comma' }, - { type: 'checkbox', name: 'add_pusher', label: 'Add pusher to recipients list' }, - { type: 'checkbox', name: 'notify_only_broken_builds' }, - ] - end - - def test(data) - begin - # bypass build status verification when testing - data[:build_status] = "failed" - data[:build_allow_failure] = false - - result = execute(data) - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result } - end - - def should_build_be_notified?(data) - case data[:build_status] - when 'success' - !notify_only_broken_builds? - when 'failed' - !allow_failure?(data) - else - false - end - end - - def allow_failure?(data) - data[:build_allow_failure] == true - end - - def all_recipients(data) - all_recipients = [] - - unless recipients.blank? - all_recipients += recipients.split(',').compact.reject(&:blank?) - end - - if add_pusher? && data[:user][:email] - all_recipients << data[:user][:email] - end - - all_recipients + %w[] end end diff --git a/app/models/project_services/chat_message/build_message.rb b/app/models/project_services/chat_message/build_message.rb deleted file mode 100644 index c776e0a20c4..00000000000 --- a/app/models/project_services/chat_message/build_message.rb +++ /dev/null @@ -1,102 +0,0 @@ -module ChatMessage - class BuildMessage < BaseMessage - attr_reader :sha - attr_reader :ref_type - attr_reader :ref - attr_reader :status - attr_reader :project_name - attr_reader :project_url - attr_reader :user_name - attr_reader :user_url - attr_reader :duration - attr_reader :stage - attr_reader :build_id - attr_reader :build_name - - def initialize(params) - @sha = params[:sha] - @ref_type = params[:tag] ? 'tag' : 'branch' - @ref = params[:ref] - @project_name = params[:project_name] - @project_url = params[:project_url] - @status = params[:commit][:status] - @user_name = params[:commit][:author_name] - @user_url = params[:commit][:author_url] - @duration = params[:commit][:duration] - @stage = params[:build_stage] - @build_name = params[:build_name] - @build_id = params[:build_id] - end - - def pretext - '' - end - - def fallback - format(message) - end - - def attachments - [{ text: format(message), color: attachment_color }] - end - - private - - def message - "#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_link} #{humanized_status} on build #{build_link} of stage #{stage} in #{duration} #{'second'.pluralize(duration)}" - end - - def build_url - "#{project_url}/builds/#{build_id}" - end - - def build_link - link(build_name, build_url) - end - - def user_link - link(user_name, user_url) - end - - def format(string) - Slack::Notifier::LinkFormatter.format(string) - end - - def humanized_status - case status - when 'success' - 'passed' - else - status - end - end - - def attachment_color - if status == 'success' - 'good' - else - 'danger' - end - end - - def branch_url - "#{project_url}/commits/#{ref}" - end - - def branch_link - link(ref, branch_url) - end - - def project_link - link(project_name, project_url) - end - - def commit_url - "#{project_url}/commit/#{sha}/builds" - end - - def commit_link - link(Commit.truncate_sha(sha), commit_url) - end - end -end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 8468934425f..200be99f36b 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -6,7 +6,7 @@ class ChatNotificationService < Service default_value_for :category, 'chat' prop_accessor :webhook, :username, :channel - boolean_accessor :notify_only_broken_builds, :notify_only_broken_pipelines + boolean_accessor :notify_only_broken_pipelines validates :webhook, presence: true, url: true, if: :activated? @@ -16,7 +16,6 @@ class ChatNotificationService < Service if properties.nil? self.properties = {} - self.notify_only_broken_builds = true self.notify_only_broken_pipelines = true end end @@ -27,7 +26,7 @@ class ChatNotificationService < Service def self.supported_events %w[push issue confidential_issue merge_request note tag_push - build pipeline wiki_page] + pipeline wiki_page] end def execute(data) @@ -89,8 +88,6 @@ class ChatNotificationService < Service ChatMessage::MergeMessage.new(data) unless is_update?(data) when "note" ChatMessage::NoteMessage.new(data) - when "build" - ChatMessage::BuildMessage.new(data) if should_build_be_notified?(data) when "pipeline" ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) when "wiki_page" @@ -125,17 +122,6 @@ class ChatNotificationService < Service data[:object_attributes][:action] == 'update' end - def should_build_be_notified?(data) - case data[:commit][:status] - when 'success' - !notify_only_broken_builds? - when 'failed' - true - else - false - end - end - def should_pipeline_be_notified?(data) case data[:object_attributes][:status] when 'success' diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index c4142c38b2f..8b181221bb0 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -9,13 +9,13 @@ class HipchatService < Service ].freeze prop_accessor :token, :room, :server, :color, :api_version - boolean_accessor :notify_only_broken_builds, :notify + boolean_accessor :notify_only_broken_pipelines, :notify validates :token, presence: true, if: :activated? def initialize_properties if properties.nil? self.properties = {} - self.notify_only_broken_builds = true + self.notify_only_broken_pipelines = true end end @@ -41,12 +41,12 @@ class HipchatService < Service placeholder: 'Leave blank for default (v2)' }, { type: 'text', name: 'server', placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_builds' }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end def self.supported_events - %w(push issue confidential_issue merge_request note tag_push build) + %w(push issue confidential_issue merge_request note tag_push pipeline) end def execute(data) @@ -90,8 +90,8 @@ class HipchatService < Service create_merge_request_message(data) unless is_update?(data) when "note" create_note_message(data) - when "build" - create_build_message(data) if should_build_be_notified?(data) + when "pipeline" + create_pipeline_message(data) if should_pipeline_be_notified?(data) end end @@ -240,28 +240,29 @@ class HipchatService < Service message end - def create_build_message(data) - ref_type = data[:tag] ? 'tag' : 'branch' - ref = data[:ref] - sha = data[:sha] - user_name = data[:commit][:author_name] - status = data[:commit][:status] - duration = data[:commit][:duration] + def create_pipeline_message(data) + pipeline_attributes = data[:object_attributes] + pipeline_id = pipeline_attributes[:id] + ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' + ref = pipeline_attributes[:ref] + user_name = (data[:user] && data[:user][:name]) || 'API' + status = pipeline_attributes[:status] + duration = pipeline_attributes[:duration] branch_link = "<a href=\"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>" - commit_link = "<a href=\"#{project_url}/commit/#{CGI.escape(sha)}/builds\">#{Commit.truncate_sha(sha)}</a>" + pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>" - "#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" + "#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" end def message_color(data) - build_status_color(data) || color || 'yellow' + pipeline_status_color(data) || color || 'yellow' end - def build_status_color(data) - return unless data && data[:object_kind] == 'build' + def pipeline_status_color(data) + return unless data && data[:object_kind] == 'pipeline' - case data[:commit][:status] + case data[:object_attributes][:status] when 'success' 'green' else @@ -294,10 +295,10 @@ class HipchatService < Service end end - def should_build_be_notified?(data) - case data[:commit][:status] + def should_pipeline_be_notified?(data) + case data[:object_attributes][:status] when 'success' - !notify_only_broken_builds? + !notify_only_broken_pipelines? when 'failed' true else diff --git a/app/models/project_services/mattermost_service.rb b/app/models/project_services/mattermost_service.rb index c13538e9fea..1156d050622 100644 --- a/app/models/project_services/mattermost_service.rb +++ b/app/models/project_services/mattermost_service.rb @@ -30,7 +30,6 @@ class MattermostService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: 'e.g. http://mattermost_host/hooks/…' }, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, - { type: 'checkbox', name: 'notify_only_broken_builds' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 375966b9efc..4d7c81a721f 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -30,7 +30,14 @@ class PrometheusService < MonitoringService end def help - 'Retrieves `container_cpu_usage_seconds_total` and `container_memory_usage_bytes` from the configured Prometheus server. An `environment` label is required on each metric to identify the Environment.' + <<-MD.strip_heredoc + Retrieves the Kubernetes node metrics `container_cpu_usage_seconds_total` + and `container_memory_usage_bytes` from the configured Prometheus server. + + If you are not using [Auto-Deploy](https://docs.gitlab.com/ee/ci/autodeploy/index.html) + or have set up your own Prometheus server, an `environment` label is required on each metric to + [identify the Environment](https://docs.gitlab.com/ce/user/project/integrations/prometheus.html#metrics-and-labels). + MD end def self.to_param diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb index da7496573ef..b657db6f9ee 100644 --- a/app/models/project_services/slack_service.rb +++ b/app/models/project_services/slack_service.rb @@ -29,7 +29,6 @@ class SlackService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: 'e.g. https://hooks.slack.com/services/…' }, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, - { type: 'checkbox', name: 'notify_only_broken_builds' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, ] end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 539b31780b3..70eef359cdd 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -42,8 +42,11 @@ class ProjectWiki url_to_repo end - def http_url_to_repo - [Gitlab.config.gitlab.url, "/", path_with_namespace, ".git"].join('') + def http_url_to_repo(user = nil) + url = "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git" + credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) + + Gitlab::UrlSanitizer.new(url, credentials: credentials).full_url end def wiki_base_path diff --git a/app/models/route.rb b/app/models/route.rb index 73574a6206b..41e6eb7cb73 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -21,7 +21,7 @@ class Route < ActiveRecord::Base attributes[:path] = route.path.sub(path_was, path) end - if name_changed? && route.name.present? + if name_changed? && name_was.present? && route.name.present? attributes[:name] = route.name.sub(name_was, name) end diff --git a/app/models/service.rb b/app/models/service.rb index 2f75a2e4e7f..e73f7e5d1a3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -215,7 +215,6 @@ class Service < ActiveRecord::Base assembla bamboo buildkite - builds_email bugzilla campfire custom_issue_tracker diff --git a/app/models/todo.rb b/app/models/todo.rb index 47789a21133..da3fa7277c2 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -48,8 +48,14 @@ class Todo < ActiveRecord::Base after_save :keep_around_commit class << self + # Priority sorting isn't displayed in the dropdown, because we don't show + # milestones, but still show something if the user has a URL with that + # selected. def sort(method) - method == "priority" ? order_by_labels_priority : order_by(method) + case method.to_s + when 'priority', 'label_priority' then order_by_labels_priority + else order_by(method) + end end # Order by priority depending on which issue/merge request the Todo belongs to diff --git a/app/models/user.rb b/app/models/user.rb index 39c1281179b..8c7ad5d5174 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -877,7 +877,7 @@ class User < ActiveRecord::Base def ci_authorized_runners @ci_authorized_runners ||= begin runner_ids = Ci::RunnerProject. - where("ci_runner_projects.gl_project_id IN (#{ci_projects_union.to_sql})"). + where("ci_runner_projects.project_id IN (#{ci_projects_union.to_sql})"). select(:runner_id) Ci::Runner.specific.where(id: runner_ids) end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 2caebb496db..c771c22f46a 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -149,7 +149,13 @@ class WikiPage end # Returns boolean True or False if this instance - # has been fully saved to disk or not. + # is the latest commit version of the page. + def latest? + !historical? + end + + # Returns boolean True or False if this instance + # has been fully created on disk or not. def persisted? @persisted == true end @@ -220,6 +226,8 @@ class WikiPage end def save(method, *args) + saved = false + project_wiki = wiki if valid? && project_wiki.send(method, *args) @@ -237,10 +245,10 @@ class WikiPage set_attributes @persisted = true + saved = true else errors.add(:base, project_wiki.error_message) if project_wiki.error_message - @persisted = false end - @persisted + saved end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 0ab9042bf24..d6a4280ce4c 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -55,13 +55,13 @@ module Ci new_builds. # don't run projects which have not enabled shared runners and builds joins(:project).where(projects: { shared_runners_enabled: true }). - joins('LEFT JOIN project_features ON ci_builds.gl_project_id = project_features.project_id'). + joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id'). where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0'). # Implement fair scheduling # this returns builds that are ordered by number of running builds # we prefer projects that don't use shared runners at all - joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.gl_project_id=project_builds.gl_project_id"). + joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id"). order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') end @@ -71,7 +71,7 @@ module Ci def running_builds_for_shared_runners Ci::Build.running.where(runner: Ci::Runner.shared). - group(:gl_project_id).select(:gl_project_id, 'count(*) AS running_builds') + group(:project_id).select(:project_id, 'count(*) AS running_builds') end def new_builds diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb index b07338d500a..673ed02f952 100644 --- a/app/services/create_branch_service.rb +++ b/app/services/create_branch_service.rb @@ -25,12 +25,12 @@ class CreateBranchService < BaseService private def create_master_branch - project.repository.commit_file( + project.repository.create_file( current_user, '/README.md', '', message: 'Add README.md', - branch_name: 'master', - update: false) + branch_name: 'master' + ) end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 9d4739e37bb..fdce542bd9e 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -6,7 +6,7 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project merge_request.target_branch = find_target_branch - merge_request.can_be_created = branches_valid? && source_branch_specified? && target_branch_specified? + merge_request.can_be_created = branches_valid? compare_branches if branches_present? assign_title_and_description if merge_request.can_be_created diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb index 1262ecbc29a..f00a33969a8 100644 --- a/app/services/merge_requests/get_urls_service.rb +++ b/app/services/merge_requests/get_urls_service.rb @@ -7,6 +7,8 @@ module MergeRequests end def execute(changes) + return [] unless project.printing_merge_request_link_enabled + branches = get_branches(changes) merge_requests_map = opened_merge_requests_from_source_branches(branches) branches.map do |branch| @@ -23,10 +25,7 @@ module MergeRequests def opened_merge_requests_from_source_branches(branches) merge_requests = MergeRequest.from_project(project).opened.from_source_branches(branches) - merge_requests.inject({}) do |hash, mr| - hash[mr.source_branch] = mr - hash - end + merge_requests.index_by(&:source_branch) end def get_branches(changes) diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb new file mode 100644 index 00000000000..44ae23fad18 --- /dev/null +++ b/app/services/notification_recipient_service.rb @@ -0,0 +1,293 @@ +# +# Used by NotificationService to determine who should receive notification +# +class NotificationRecipientService + attr_reader :project + + def initialize(project) + @project = project + end + + def build_recipients(target, current_user, action: nil, previous_assignee: nil, skip_current_user: true) + custom_action = build_custom_key(action, target) + + recipients = target.participants(current_user) + + unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action) + recipients = add_project_watchers(recipients) + end + + recipients = add_custom_notifications(recipients, custom_action) + recipients = reject_mention_users(recipients) + + # Re-assign is considered as a mention of the new assignee so we add the + # new assignee to the list of recipients after we rejected users with + # the "on mention" notification level + if [:reassign_merge_request, :reassign_issue].include?(custom_action) + recipients << previous_assignee if previous_assignee + recipients << target.assignee + end + + recipients = reject_muted_users(recipients) + recipients = add_subscribed_users(recipients, target) + + if [:new_issue, :new_merge_request].include?(custom_action) + recipients = add_labels_subscribers(recipients, target) + end + + recipients = reject_unsubscribed_users(recipients, target) + recipients = reject_users_without_access(recipients, target) + + recipients.delete(current_user) if skip_current_user + + recipients.uniq + end + + def build_relabeled_recipients(target, current_user, labels:) + recipients = add_labels_subscribers([], target, labels: labels) + recipients = reject_unsubscribed_users(recipients, target) + recipients = reject_users_without_access(recipients, target) + recipients.delete(current_user) + recipients.uniq + end + + def build_new_note_recipients(note) + target = note.noteable + + ability, subject = if note.for_personal_snippet? + [:read_personal_snippet, note.noteable] + else + [:read_project, note.project] + end + + mentioned_users = note.mentioned_users.select { |user| user.can?(ability, subject) } + + # Add all users participating in the thread (author, assignee, comment authors) + recipients = + if target.respond_to?(:participants) + target.participants(note.author) + else + mentioned_users + end + + unless note.for_personal_snippet? + # Merge project watchers + recipients = add_project_watchers(recipients) + + # Merge project with custom notification + recipients = add_custom_notifications(recipients, :new_note) + end + + # Reject users with Mention notification level, except those mentioned in _this_ note. + recipients = reject_mention_users(recipients - mentioned_users) + recipients = recipients + mentioned_users + + recipients = reject_muted_users(recipients) + + recipients = add_subscribed_users(recipients, note.noteable) + recipients = reject_unsubscribed_users(recipients, note.noteable) + recipients = reject_users_without_access(recipients, note.noteable) + + recipients.delete(note.author) + recipients.uniq + end + + # Remove users with disabled notifications from array + # Also remove duplications and nil recipients + def reject_muted_users(users) + reject_users(users, :disabled) + end + + protected + + # Get project/group users with CUSTOM notification level + def add_custom_notifications(recipients, action) + user_ids = [] + + # Users with a notification setting on group or project + user_ids += user_ids_notifiable_on(project, :custom, action) + user_ids += user_ids_notifiable_on(project.group, :custom, action) + + # Users with global level custom + user_ids_with_project_level_global = user_ids_notifiable_on(project, :global) + user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global) + + global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global) + user_ids += user_ids_with_global_level_custom(global_users_ids, action) + + recipients.concat(User.find(user_ids)) + end + + def add_project_watchers(recipients) + recipients.concat(project_watchers).compact + end + + # Get project users with WATCH notification level + def project_watchers + project_members_ids = user_ids_notifiable_on(project) + + user_ids_with_project_global = user_ids_notifiable_on(project, :global) + user_ids_with_group_global = user_ids_notifiable_on(project.group, :global) + + user_ids = user_ids_with_global_level_watch((user_ids_with_project_global + user_ids_with_group_global).uniq) + + user_ids_with_project_setting = select_project_members_ids(project, user_ids_with_project_global, user_ids) + user_ids_with_group_setting = select_group_members_ids(project.group, project_members_ids, user_ids_with_group_global, user_ids) + + User.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq).to_a + end + + # Remove users with notification level 'Mentioned' + def reject_mention_users(users) + reject_users(users, :mention) + end + + def add_subscribed_users(recipients, target) + return recipients unless target.respond_to? :subscribers + + recipients + target.subscribers(project) + end + + def user_ids_notifiable_on(resource, notification_level = nil, action = nil) + return [] unless resource + + if notification_level + settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level]) + settings = settings.select { |setting| setting.events[action] } if action.present? + settings.map(&:user_id) + else + resource.notification_settings.pluck(:user_id) + end + end + + # Build a list of user_ids based on project notification settings + def select_project_members_ids(project, global_setting, user_ids_global_level_watch) + user_ids = user_ids_notifiable_on(project, :watch) + + # If project setting is global, add to watch list if global setting is watch + global_setting.each do |user_id| + if user_ids_global_level_watch.include?(user_id) + user_ids << user_id + end + end + + user_ids + end + + # Build a list of user_ids based on group notification settings + def select_group_members_ids(group, project_members, global_setting, user_ids_global_level_watch) + uids = user_ids_notifiable_on(group, :watch) + + # Group setting is watch, add to user_ids list if user is not project member + user_ids = [] + uids.each do |user_id| + if project_members.exclude?(user_id) + user_ids << user_id + end + end + + # Group setting is global, add to user_ids list if global setting is watch + global_setting.each do |user_id| + if project_members.exclude?(user_id) && user_ids_global_level_watch.include?(user_id) + user_ids << user_id + end + end + + user_ids + end + + def user_ids_with_global_level_watch(ids) + settings_with_global_level_of(:watch, ids).pluck(:user_id) + end + + def user_ids_with_global_level_custom(ids, action) + settings = settings_with_global_level_of(:custom, ids) + settings = settings.select { |setting| setting.events[action] } + settings.map(&:user_id) + end + + def settings_with_global_level_of(level, ids) + NotificationSetting.where( + user_id: ids, + source_type: nil, + level: NotificationSetting.levels[level] + ) + end + + # Reject users which has certain notification level + # + # Example: + # reject_users(users, :watch, project) + # + def reject_users(users, level) + level = level.to_s + + unless NotificationSetting.levels.keys.include?(level) + raise 'Invalid notification level' + end + + users = users.to_a.compact.uniq + users = users.select { |u| u.can?(:receive_notifications) } + + users.reject do |user| + global_notification_setting = user.global_notification_setting + + next global_notification_setting.level == level unless project + + setting = user.notification_settings_for(project) + + if project.group && (setting.nil? || setting.global?) + setting = user.notification_settings_for(project.group) + end + + # reject users who globally set mention notification and has no setting per project/group + next global_notification_setting.level == level unless setting + + # reject users who set mention notification in project + next true if setting.level == level + + # reject users who have mention level in project and disabled in global settings + setting.global? && global_notification_setting.level == level + end + end + + def reject_unsubscribed_users(recipients, target) + return recipients unless target.respond_to? :subscriptions + + recipients.reject do |user| + subscription = target.subscriptions.find_by_user_id(user.id) + subscription && !subscription.subscribed + end + end + + def reject_users_without_access(recipients, target) + ability = case target + when Issuable + :"read_#{target.to_ability_name}" + when Ci::Pipeline + :read_build # We have build trace in pipeline emails + end + + return recipients unless ability + + recipients.select do |user| + user.can?(ability, target) + end + end + + def add_labels_subscribers(recipients, target, labels: nil) + return recipients unless target.respond_to? :labels + + (labels || target.labels).each do |label| + recipients += label.subscribers(project) + end + + recipients + end + + # Build event key to search on custom notification level + # Check NotificationSetting::EMAIL_EVENTS + def build_custom_key(action, object) + "#{action}_#{object.class.model_name.name.underscore}".to_sym + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index d12692ecc90..f9aa2346759 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -150,7 +150,10 @@ class NotificationService end def resolve_all_discussions(merge_request, current_user) - recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions") + recipients = NotificationRecipientService.new(merge_request.target_project).build_recipients( + merge_request, + current_user, + action: "resolve_all_discussions") recipients.each do |recipient| mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later @@ -164,64 +167,15 @@ class NotificationService end # Notify users on new note in system - # - # TODO: split on methods and refactor - # def new_note(note) return true unless note.noteable_type.present? # ignore gitlab service messages return true if note.cross_reference? && note.system? - target = note.noteable - - recipients = [] - - mentioned_users = note.mentioned_users - - ability, subject = if note.for_personal_snippet? - [:read_personal_snippet, note.noteable] - else - [:read_project, note.project] - end - - mentioned_users.select! do |user| - user.can?(ability, subject) - end - - # Add all users participating in the thread (author, assignee, comment authors) - participants = - if target.respond_to?(:participants) - target.participants(note.author) - else - mentioned_users - end - - recipients = recipients.concat(participants) - - unless note.for_personal_snippet? - # Merge project watchers - recipients = add_project_watchers(recipients, note.project) - - # Merge project with custom notification - recipients = add_custom_notifications(recipients, note.project, :new_note) - end - - # Reject users with Mention notification level, except those mentioned in _this_ note. - recipients = reject_mention_users(recipients - mentioned_users, note.project) - recipients = recipients + mentioned_users - - recipients = reject_muted_users(recipients, note.project) - - recipients = add_subscribed_users(recipients, note.project, note.noteable) - recipients = reject_unsubscribed_users(recipients, note.noteable) - recipients = reject_users_without_access(recipients, note.noteable) - - recipients.delete(note.author) unless note.author.notified_of_own_activity? - recipients = recipients.uniq - notify_method = "note_#{note.to_ability_name}_email".to_sym + recipients = NotificationRecipientService.new(note.project).build_new_note_recipients(note) recipients.each do |recipient| mailer.send(notify_method, recipient.id, note.id).deliver_later end @@ -290,7 +244,7 @@ class NotificationService def project_was_moved(project, old_path_with_namespace) recipients = project.team.members - recipients = reject_muted_users(recipients, project) + recipients = NotificationRecipientService.new(project).reject_muted_users(recipients) recipients.each do |recipient| mailer.project_was_moved_email( @@ -302,7 +256,7 @@ class NotificationService end def issue_moved(issue, new_issue, current_user) - recipients = build_recipients(issue, issue.project, current_user) + recipients = NotificationRecipientService.new(issue.project).build_recipients(issue, current_user) recipients.map do |recipient| email = mailer.issue_moved_email(recipient, issue, new_issue, current_user) @@ -324,12 +278,10 @@ class NotificationService return unless mailer.respond_to?(email_template) - recipients ||= build_recipients( + recipients ||= NotificationRecipientService.new(pipeline.project).build_recipients( pipeline, - pipeline.project, - pipeline.user, - action: pipeline.status, - skip_current_user: false).map(&:notification_email) + nil, # The acting user, who won't be added to recipients + action: pipeline.status).map(&:notification_email) if recipients.any? mailer.public_send(email_template, pipeline, recipients).deliver_later @@ -338,199 +290,8 @@ class NotificationService protected - # Get project/group users with CUSTOM notification level - def add_custom_notifications(recipients, project, action) - user_ids = [] - - # Users with a notification setting on group or project - user_ids += notification_settings_for(project, :custom, action) - user_ids += notification_settings_for(project.group, :custom, action) - - # Users with global level custom - users_with_project_level_global = notification_settings_for(project, :global) - users_with_group_level_global = notification_settings_for(project.group, :global) - - global_users_ids = users_with_project_level_global.concat(users_with_group_level_global) - user_ids += users_with_global_level_custom(global_users_ids, action) - - recipients.concat(User.find(user_ids)) - end - - # Get project users with WATCH notification level - def project_watchers(project) - project_members = notification_settings_for(project) - - users_with_project_level_global = notification_settings_for(project, :global) - users_with_group_level_global = notification_settings_for(project.group, :global) - - users = users_with_global_level_watch([users_with_project_level_global, users_with_group_level_global].flatten.uniq) - - users_with_project_setting = select_project_member_setting(project, users_with_project_level_global, users) - users_with_group_setting = select_group_member_setting(project.group, project_members, users_with_group_level_global, users) - - User.where(id: users_with_project_setting.concat(users_with_group_setting).uniq).to_a - end - - def notification_settings_for(resource, notification_level = nil, action = nil) - return [] unless resource - - if notification_level - settings = resource.notification_settings.where(level: NotificationSetting.levels[notification_level]) - settings = settings.select { |setting| setting.events[action] } if action.present? - settings.map(&:user_id) - else - resource.notification_settings.pluck(:user_id) - end - end - - def users_with_global_level_watch(ids) - settings_with_global_level_of(:watch, ids).pluck(:user_id) - end - - def users_with_global_level_custom(ids, action) - settings = settings_with_global_level_of(:custom, ids) - settings = settings.select { |setting| setting.events[action] } - settings.map(&:user_id) - end - - def settings_with_global_level_of(level, ids) - NotificationSetting.where( - user_id: ids, - source_type: nil, - level: NotificationSetting.levels[level] - ) - end - - # Build a list of users based on project notification settings - def select_project_member_setting(project, global_setting, users_global_level_watch) - users = notification_settings_for(project, :watch) - - # If project setting is global, add to watch list if global setting is watch - global_setting.each do |user_id| - if users_global_level_watch.include?(user_id) - users << user_id - end - end - - users - end - - # Build a list of users based on group notification settings - def select_group_member_setting(group, project_members, global_setting, users_global_level_watch) - uids = notification_settings_for(group, :watch) - - # Group setting is watch, add to users list if user is not project member - users = [] - uids.each do |user_id| - if project_members.exclude?(user_id) - users << user_id - end - end - - # Group setting is global, add to users list if global setting is watch - global_setting.each do |user_id| - if project_members.exclude?(user_id) && users_global_level_watch.include?(user_id) - users << user_id - end - end - - users - end - - def add_project_watchers(recipients, project) - recipients.concat(project_watchers(project)).compact - end - - # Remove users with disabled notifications from array - # Also remove duplications and nil recipients - def reject_muted_users(users, project = nil) - reject_users(users, :disabled, project) - end - - # Remove users with notification level 'Mentioned' - def reject_mention_users(users, project = nil) - reject_users(users, :mention, project) - end - - # Reject users which has certain notification level - # - # Example: - # reject_users(users, :watch, project) - # - def reject_users(users, level, project = nil) - level = level.to_s - - unless NotificationSetting.levels.keys.include?(level) - raise 'Invalid notification level' - end - - users = users.to_a.compact.uniq - users = users.select { |u| u.can?(:receive_notifications) } - - users.reject do |user| - global_notification_setting = user.global_notification_setting - - next global_notification_setting.level == level unless project - - setting = user.notification_settings_for(project) - - if project.group && (setting.nil? || setting.global?) - setting = user.notification_settings_for(project.group) - end - - # reject users who globally set mention notification and has no setting per project/group - next global_notification_setting.level == level unless setting - - # reject users who set mention notification in project - next true if setting.level == level - - # reject users who have mention level in project and disabled in global settings - setting.global? && global_notification_setting.level == level - end - end - - def reject_unsubscribed_users(recipients, target) - return recipients unless target.respond_to? :subscriptions - - recipients.reject do |user| - subscription = target.subscriptions.find_by_user_id(user.id) - subscription && !subscription.subscribed - end - end - - def reject_users_without_access(recipients, target) - ability = case target - when Issuable - :"read_#{target.to_ability_name}" - when Ci::Pipeline - :read_build # We have build trace in pipeline emails - end - - return recipients unless ability - - recipients.select do |user| - user.can?(ability, target) - end - end - - def add_subscribed_users(recipients, project, target) - return recipients unless target.respond_to? :subscribers - - recipients + target.subscribers(project) - end - - def add_labels_subscribers(recipients, project, target, labels: nil) - return recipients unless target.respond_to? :labels - - (labels || target.labels).each do |label| - recipients += label.subscribers(project) - end - - recipients - end - def new_resource_email(target, project, method) - recipients = build_recipients(target, project, target.author, action: "new") + recipients = NotificationRecipientService.new(project).build_recipients(target, target.author, action: "new") recipients.each do |recipient| mailer.send(method, recipient.id, target.id).deliver_later @@ -538,7 +299,7 @@ class NotificationService end def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method) - recipients = build_recipients(target, project, current_user, action: "new") + recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "new") recipients = recipients & new_mentioned_users recipients.each do |recipient| @@ -549,9 +310,8 @@ class NotificationService def close_resource_email(target, project, current_user, method, skip_current_user: true) action = method == :merged_merge_request_email ? "merge" : "close" - recipients = build_recipients( + recipients = NotificationRecipientService.new(project).build_recipients( target, - project, current_user, action: action, skip_current_user: skip_current_user @@ -566,7 +326,12 @@ class NotificationService previous_assignee_id = previous_record(target, 'assignee_id') previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id - recipients = build_recipients(target, project, current_user, action: "reassign", previous_assignee: previous_assignee) + recipients = NotificationRecipientService.new(project).build_recipients( + target, + current_user, + action: "reassign", + previous_assignee: previous_assignee + ) recipients.each do |recipient| mailer.send( @@ -580,7 +345,7 @@ class NotificationService end def relabeled_resource_email(target, project, labels, current_user, method) - recipients = build_relabeled_recipients(target, project, current_user, labels: labels) + recipients = NotificationRecipientService.new(project).build_relabeled_recipients(target, current_user, labels: labels) label_names = labels.map(&:name) recipients.each do |recipient| @@ -589,58 +354,13 @@ class NotificationService end def reopen_resource_email(target, project, current_user, method, status) - recipients = build_recipients(target, project, current_user, action: "reopen") + recipients = NotificationRecipientService.new(project).build_recipients(target, current_user, action: "reopen") recipients.each do |recipient| mailer.send(method, recipient.id, target.id, status, current_user.id).deliver_later end end - def build_recipients(target, project, current_user, action: nil, previous_assignee: nil, skip_current_user: true) - custom_action = build_custom_key(action, target) - - recipients = target.participants(current_user) - - unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action) - recipients = add_project_watchers(recipients, project) - end - - recipients = add_custom_notifications(recipients, project, custom_action) - recipients = reject_mention_users(recipients, project) - - recipients = recipients.uniq - - # Re-assign is considered as a mention of the new assignee so we add the - # new assignee to the list of recipients after we rejected users with - # the "on mention" notification level - if [:reassign_merge_request, :reassign_issue].include?(custom_action) - recipients << previous_assignee if previous_assignee - recipients << target.assignee - end - - recipients = reject_muted_users(recipients, project) - recipients = add_subscribed_users(recipients, project, target) - - if [:new_issue, :new_merge_request].include?(custom_action) - recipients = add_labels_subscribers(recipients, project, target) - end - - recipients = reject_unsubscribed_users(recipients, target) - recipients = reject_users_without_access(recipients, target) - - recipients.delete(current_user) if skip_current_user && !current_user.notified_of_own_activity? - - recipients.uniq - end - - def build_relabeled_recipients(target, project, current_user, labels:) - recipients = add_labels_subscribers([], project, target, labels: labels) - recipients = reject_unsubscribed_users(recipients, target) - recipients = reject_users_without_access(recipients, target) - recipients.delete(current_user) unless current_user.notified_of_own_activity? - recipients.uniq - end - def mailer Notify end @@ -652,10 +372,4 @@ class NotificationService end end end - - # Build event key to search on custom notification level - # Check NotificationSetting::EMAIL_EVENTS - def build_custom_key(action, object) - "#{action}_#{object.class.model_name.name.underscore}".to_sym - end end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 1c5a549feb9..d484a96f785 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -33,6 +33,7 @@ module Projects def import_repository begin + raise Error, "Blocked import URL." if Gitlab::UrlBlocker.blocked_url?(project.import_url) gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url) rescue => e # Expire cache to prevent scenarios such as: @@ -40,7 +41,7 @@ module Projects # 2. Retried import, repo is broken or not imported but +exists?+ still returns true project.repository.before_import if project.repository_exists? - raise Error, "Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}" + raise Error, "Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}" end end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index 868fa7b3f21..af0ddbe5934 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -24,10 +24,9 @@ class SystemHooksService key: model.key, id: model.id ) + if model.user - data.merge!( - username: model.user.username - ) + data[:username] = model.user.username end when Project data.merge!(project_data(model)) @@ -35,8 +34,6 @@ class SystemHooksService if event == :rename || event == :transfer data[:old_path_with_namespace] = model.old_path_with_namespace end - - data when User data.merge!({ name: model.name, @@ -59,6 +56,8 @@ class SystemHooksService when GroupMember data.merge!(group_member_data(model)) end + + data end def build_event_name(model, event) diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 8787a1c93a9..bf7e76ec59e 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -201,10 +201,12 @@ class TodoService def update_todos_state_by_ids(ids, current_user, state) todos = current_user.todos.where(id: ids) - # Only return those that are not really on that state - marked_todos = todos.where.not(state: state).update_all(state: state) + # Only update those that are not really on that state + todos = todos.where.not(state: state) + todos_ids = todos.pluck(:id) + todos.update_all(state: state) current_user.update_todos_count_cache - marked_todos + todos_ids end def create_todos(users, attributes) diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index d9370bbb598..8f6f5b937c4 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -93,9 +93,7 @@ module Users end def current_authorizations_per_project - current_authorizations.each_with_object({}) do |row, hash| - hash[row.project_id] = row - end + current_authorizations.index_by(&:project_id) end def current_authorizations diff --git a/app/validators/importable_url_validator.rb b/app/validators/importable_url_validator.rb new file mode 100644 index 00000000000..37a314adee6 --- /dev/null +++ b/app/validators/importable_url_validator.rb @@ -0,0 +1,11 @@ +# ImportableUrlValidator +# +# This validator blocks projects from using dangerous import_urls to help +# protect against Server-side Request Forgery (SSRF). +class ImportableUrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if Gitlab::UrlBlocker.blocked_url?(value) + record.errors.add(attribute, "imports are not allowed from that URL") + end + end +end diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index 9175b3d3f96..e403a9da616 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -48,7 +48,7 @@ .form-actions = f.submit 'Save', class: 'btn btn-save append-right-10' - if @appearance.persisted? - = link_to 'Preview last save', preview_admin_appearances_path, class: 'btn', target: '_blank' + = link_to 'Preview last save', preview_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - if @appearance.updated_at %span.pull-right diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 00366b0a8c9..3eab065bb9f 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -404,7 +404,7 @@ Enable Sentry .help-block Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: - %a{ href: 'https://getsentry.com', target: '_blank' } https://getsentry.com + %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com .form-group = f.label :sentry_dsn, 'Sentry DSN', class: 'control-label col-sm-2' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 388190642aa..d0c12aa57ae 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -36,14 +36,14 @@ - if todo.pending? .todo-actions - = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading js-done-todo' do + = link_to dashboard_todo_path(todo), method: :delete, class: 'btn btn-loading js-done-todo', data: { href: dashboard_todo_path(todo) } do Done = icon('spinner spin') - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'btn btn-loading js-undo-todo hidden' do + = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'btn btn-loading js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do Undo = icon('spinner spin') - else .todo-actions - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'btn btn-loading js-add-todo' do + = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'btn btn-loading js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do Add todo = icon('spinner spin') diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index d7e0a8e4b2c..d31ced004a0 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -19,9 +19,12 @@ .nav-controls - if @todos.any?(&:pending?) - = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn btn-loading js-todos-mark-all', method: :delete do + = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn btn-loading js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do Mark all as done = icon('spinner spin') + = link_to bulk_restore_dashboard_todos_path, class: 'btn btn-loading js-todos-undo-all hidden', method: :patch , data: { href: bulk_restore_dashboard_todos_path(todos_filter_params) } do + Undo mark all as done + = icon('spinner spin') .todos-filters .row-content-block.second-block @@ -57,8 +60,8 @@ = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-sort %li - = link_to todos_filter_path(sort: sort_value_priority) do - = sort_title_priority + = link_to todos_filter_path(sort: sort_value_label_priority) do + = sort_title_label_priority = link_to todos_filter_path(sort: sort_value_recently_created) do = sort_title_recently_created = link_to todos_filter_path(sort: sort_value_oldest_created) do @@ -67,12 +70,16 @@ .js-todos-all - if @todos.any? - .js-todos-options{ data: {per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages} } - .panel.panel-default.panel-small.panel-without-border - %ul.content-list.todos-list - = render @todos - = paginate @todos, theme: "gitlab" - + .js-todos-list-container + .js-todos-options{ data: { per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages } } + .panel.panel-default.panel-small.panel-without-border + %ul.content-list.todos-list + = render @todos + = paginate @todos, theme: "gitlab" + .js-nothing-here-container.todos-all-done.hidden + = render "shared/empty_states/icons/todos_all_done.svg" + %h4.text-center + You're all done! - elsif current_user.todos.any? .todos-all-done = render "shared/empty_states/icons/todos_all_done.svg" diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder index 43a52cf3002..158061579f6 100644 --- a/app/views/events/_event.atom.builder +++ b/app/views/events/_event.atom.builder @@ -9,7 +9,7 @@ xml.entry do xml.author do xml.name event.author_name - xml.email event.author_email + xml.email event.author_public_email end xml.summary(type: "xhtml") do |summary| diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index f08c96df309..64b5a733b77 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -15,6 +15,6 @@ = link_to note.attachment.url, target: '_blank' do = image_tag note.attachment.url, class: 'note-image-attach' - else - = link_to note.attachment.url, target: "_blank", class: 'note-file-attach' do + = link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do %i.fa.fa-paperclip = note.attachment_identifier diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 8374f5a009f..bb2cd0d44c8 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -11,5 +11,3 @@ = render 'groups' - else .nothing-here-block No public groups - -= paginate @groups, theme: "gitlab" diff --git a/app/views/groups/_settings_head.html.haml b/app/views/groups/_settings_head.html.haml new file mode 100644 index 00000000000..2454e7355a7 --- /dev/null +++ b/app/views/groups/_settings_head.html.haml @@ -0,0 +1,14 @@ += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: container_class } + = nav_link(path: 'groups#edit') do + = link_to edit_group_path(@group), title: 'General' do + %span + General + + = nav_link(path: 'groups#projects') do + = link_to projects_group_path(@group), title: 'Projects' do + %span + Projects diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 2706e8692d1..80a77dab97f 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,3 +1,4 @@ += render "groups/settings_head" .panel.panel-default.prepend-top-default .panel-heading Group settings diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 2e7e5e5c309..83bdd654f27 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -1,4 +1,4 @@ -- page_title "Projects" += render "groups/settings_head" .panel.panel-default.prepend-top-default .panel-heading diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 31631887317..f93b6b63426 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -17,7 +17,7 @@ %br Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises. %br - Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank'}. + Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer'}. - if current_application_settings.help_page_text.present? %hr = markdown_field(current_application_settings, :help_page_text) diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index e18bd47798b..e6058617ac9 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -33,7 +33,7 @@ - @already_added_projects.each do |project| %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } %td - = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank' + = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank', rel: 'noopener noreferrer' %td = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project] %td.job-status @@ -50,7 +50,7 @@ - @repos.each do |repo| %tr{ id: "repo_#{repo.owner}___#{repo.slug}" } %td - = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: "_blank" + = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer' %td.import-target %fieldset.row .input-group @@ -70,7 +70,7 @@ - @incompatible_repos.each do |repo| %tr{ id: "repo_#{repo.owner}___#{repo.slug}" } %td - = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank' + = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank', rel: 'noopener noreferrer' %td.import-target %td.import-actions-job-status = label_tag 'Incompatible Project', nil, class: 'label label-danger' diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index d5b88709a34..7456799ca0e 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -43,7 +43,7 @@ - @repos.each do |repo| %tr{ id: "repo_#{repo["id"]}" } %td - = link_to repo["path_with_namespace"], "https://gitlab.com/#{repo["path_with_namespace"]}", target: "_blank" + = link_to repo["path_with_namespace"], "https://gitlab.com/#{repo["path_with_namespace"]}", target: "_blank", rel: 'noopener noreferrer' %td.import-target = import_project_target(repo['namespace']['path'], repo['name']) %td.import-actions.job-status diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml index 336becd229e..c5800a1cca0 100644 --- a/app/views/import/google_code/new.html.haml +++ b/app/views/import/google_code/new.html.haml @@ -13,7 +13,7 @@ %li %p Go to - #{link_to "Google Takeout", "https://www.google.com/settings/takeout", target: "_blank"}. + #{link_to "Google Takeout", "https://www.google.com/settings/takeout", target: '_blank', rel: 'noopener noreferrer'}. %li %p Make sure you're logged into the account that owns the projects you'd like to import. diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml index 5e01af008be..60de6bfe816 100644 --- a/app/views/import/google_code/status.html.haml +++ b/app/views/import/google_code/status.html.haml @@ -36,7 +36,7 @@ - @already_added_projects.each do |project| %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } %td - = link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank" + = link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank", rel: 'noopener noreferrer' %td = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project] %td.job-status @@ -53,7 +53,7 @@ - @repos.each do |repo| %tr{ id: "repo_#{repo.id}" } %td - = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank" + = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank", rel: 'noopener noreferrer' %td.import-target #{current_user.username}/#{repo.name} %td.import-actions.job-status @@ -63,7 +63,7 @@ - @incompatible_repos.each do |repo| %tr{ id: "repo_#{repo.id}" } %td - = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank" + = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank", rel: 'noopener noreferrer' %td.import-target %td.import-actions-job-status = label_tag "Incompatible Project", nil, class: "label label-danger" diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index fcd30c8c765..23a88448055 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -7,7 +7,7 @@ xml.entry do xml.author do xml.name issue.author_name - xml.email issue.author_email + xml.email issue.author_public_email end xml.summary issue.title @@ -26,7 +26,7 @@ xml.entry do if issue.assignee xml.assignee do xml.name issue.assignee.name - xml.email issue.assignee.email + xml.email issue.assignee_public_email end end end diff --git a/app/views/koding/index.html.haml b/app/views/koding/index.html.haml index 65887aacbaf..04e2d4b63e6 100644 --- a/app/views/koding/index.html.haml +++ b/app/views/koding/index.html.haml @@ -2,5 +2,5 @@ %p = icon('circle', class: 'cgreen') Integration is active for - = link_to koding_project_url, target: '_blank' do + = link_to koding_project_url, target: '_blank', rel: 'noopener noreferrer' do #{current_application_settings.koding_url} diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 6f4f2dbea3a..5fde5c2613e 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -67,7 +67,7 @@ = link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do = brand_header_logo - %h1.title= title + %h1.title{ class: ('initializing' if @has_group_title) }= title = yield :header_content diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index a6e96942021..8605380848d 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -1,4 +1,3 @@ -= render 'layouts/nav/group_settings' .scrolling-tabs-container{ class: nav_control_class } .fade-left = icon('angle-left') @@ -25,3 +24,8 @@ = link_to group_group_members_path(@group), title: 'Members' do %span Members + - if current_user && can?(current_user, :admin_group, @group) + = nav_link(path: %w[groups#projects groups#edit]) do + = link_to edit_group_path(@group), title: 'Settings' do + %span + Settings diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml deleted file mode 100644 index 30feb6813b4..00000000000 --- a/app/views/layouts/nav/_group_settings.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- if current_user - - can_admin_group = can?(current_user, :admin_group, @group) - - can_edit = can?(current_user, :admin_group, @group) - - - if can_admin_group || can_edit - .controls - .dropdown.group-settings-dropdown - %a.dropdown-new.btn.btn-default#group-settings-button{ href: '#', 'data-toggle' => 'dropdown' } - = icon('cog') - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - - if can_admin_group - = nav_link(path: 'groups#projects') do - = link_to 'Projects', projects_group_path(@group), title: 'Projects' - - if can_edit && can_admin_group - %li.divider - %li - = link_to 'Edit Group', edit_group_path(@group) diff --git a/app/views/notify/build_fail_email.html.haml b/app/views/notify/build_fail_email.html.haml deleted file mode 100644 index 060b50ffc69..00000000000 --- a/app/views/notify/build_fail_email.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -- content_for :header do - %h1{ style: "background: #c40834; color: #FFF; font: normal 20px Helvetica, Arial, sans-serif; margin: 0; padding: 5px 10px; line-height: 32px; font-size: 16px;" } - GitLab (job failed) - -%h3 - Project: - = link_to namespace_project_url(@project.namespace, @project) do - = @project.name - -%p - Commit: #{link_to @build.short_sha, namespace_project_commit_url(@build.project.namespace, @build.project, @build.sha)} -%p - Author: #{@build.pipeline.git_author_name} -%p - Branch: #{@build.ref} -%p - Stage: #{@build.stage} -%p - Job: #{@build.name} -%p - Message: #{@build.pipeline.git_commit_message} - -%p - Job details: #{link_to "Job #{@build.id}", namespace_project_build_url(@build.project.namespace, @build.project, @build)} diff --git a/app/views/notify/build_fail_email.text.erb b/app/views/notify/build_fail_email.text.erb deleted file mode 100644 index 2a94688a6b0..00000000000 --- a/app/views/notify/build_fail_email.text.erb +++ /dev/null @@ -1,11 +0,0 @@ -Job failed for <%= @project.name %> - -Status: <%= @build.status %> -Commit: <%= @build.pipeline.short_sha %> -Author: <%= @build.pipeline.git_author_name %> -Branch: <%= @build.ref %> -Stage: <%= @build.stage %> -Job: <%= @build.name %> -Message: <%= @build.pipeline.git_commit_message %> - -Url: <%= namespace_project_build_url(@build.project.namespace, @build.project, @build) %> diff --git a/app/views/notify/build_success_email.html.haml b/app/views/notify/build_success_email.html.haml deleted file mode 100644 index ca0eaa96a9d..00000000000 --- a/app/views/notify/build_success_email.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -- content_for :header do - %h1{ style: "background: #38CF5B; color: #FFF; font: normal 20px Helvetica, Arial, sans-serif; margin: 0; padding: 5px 10px; line-height: 32px; font-size: 16px;" } - GitLab (job successful) - -%h3 - Project: - = link_to namespace_project_url(@project.namespace, @project) do - = @project.name - -%p - Commit: #{link_to @build.short_sha, namespace_project_commit_url(@build.project.namespace, @build.project, @build.sha)} -%p - Author: #{@build.pipeline.git_author_name} -%p - Branch: #{@build.ref} -%p - Stage: #{@build.stage} -%p - Job: #{@build.name} -%p - Message: #{@build.pipeline.git_commit_message} - -%p - Job details: #{link_to "Job #{@build.id}", namespace_project_build_url(@build.project.namespace, @build.project, @build)} diff --git a/app/views/notify/build_success_email.text.erb b/app/views/notify/build_success_email.text.erb deleted file mode 100644 index 445cd46e64f..00000000000 --- a/app/views/notify/build_success_email.text.erb +++ /dev/null @@ -1,11 +0,0 @@ -Job successful for <%= @project.name %> - -Status: <%= @build.status %> -Commit: <%= @build.pipeline.short_sha %> -Author: <%= @build.pipeline.git_author_name %> -Branch: <%= @build.ref %> -Stage: <%= @build.stage %> -Job: <%= @build.name %> -Message: <%= @build.pipeline.git_commit_message %> - -Url: <%= namespace_project_build_url(@build.project.namespace, @build.project, @build) %> diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 51c4e8e5a73..5c5e5940365 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -34,11 +34,6 @@ .clearfix - = form_for @user, url: profile_notifications_path, method: :put do |f| - %label{ for: 'user_notified_of_own_activity' } - = f.check_box :notified_of_own_activity - %span Receive notifications about your own activity - %hr %h5 Groups (#{@group_notifications.count}) diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index d551754a2e5..c74b3249a13 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -18,7 +18,7 @@ or change it at #{link_to Gitlab.config.gravatar.host, "http://" + Gitlab.config.gravatar.host} .col-lg-9 .clearfix.avatar-image.append-bottom-default - = link_to avatar_icon(@user, 400), target: '_blank' do + = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon(@user, 160), alt: '', class: 'avatar s160' %h5.prepend-top-0 Upload new avatar diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml index 188198c47d5..61420fd0fb6 100644 --- a/app/views/projects/_merge_request_merge_settings.html.haml +++ b/app/views/projects/_merge_request_merge_settings.html.haml @@ -13,3 +13,7 @@ = form.label :only_allow_merge_if_all_discussions_are_resolved do = form.check_box :only_allow_merge_if_all_discussions_are_resolved %strong Only allow merge requests to be merged if all discussions are resolved + .checkbox + = form.label :printing_merge_request_link_enabled do + = form.check_box :printing_merge_request_link_enabled + %strong Show link to create/view merge request when pushing from the command line diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 8a40281e28c..4ad77b6266d 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -7,13 +7,8 @@ #blob-content-holder.tree-holder .file-holder - .js-file-title.file-title - = blob_icon @blob.mode, @blob.name - %strong - = @path - %small= number_to_human_size @blob.size - .file-actions - = render "projects/blob/actions" + = render "projects/blob/header", blob: @blob + .table-responsive.file-content.blame.code.js-syntax-highlight %table - current_line = 1 diff --git a/app/views/projects/blob/_actions.html.haml b/app/views/projects/blob/_actions.html.haml deleted file mode 100644 index 14d42f7d9ec..00000000000 --- a/app/views/projects/blob/_actions.html.haml +++ /dev/null @@ -1,26 +0,0 @@ -- if @environment - .btn-group< - = view_on_environment_button(@commit.sha, @path, @environment) - -.btn-group{ role: "group" }< - = link_to 'Raw', namespace_project_raw_path(@project.namespace, @project, @id), - class: 'btn btn-sm', target: '_blank' - -# only show normal/blame view links for text files - - if blob_text_viewable?(@blob) - - if current_page? namespace_project_blame_path(@project.namespace, @project, @id) - = link_to 'Normal View', namespace_project_blob_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - else - = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), - class: 'btn btn-sm js-blob-blame-link' unless @blob.empty? - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, - tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' - -- if current_user - .btn-group{ role: "group" }< - - if blob_text_viewable?(@blob) - = edit_blob_link - = replace_blob_link - = delete_blob_link diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index bf8801bb1e3..2b2ee6ed987 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -24,13 +24,5 @@ #blob-content-holder.blob-content-holder %article.file-holder - .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon blob.mode, blob.name - %strong.file-title-name - = blob.name - %small - = number_to_human_size(blob_size(blob)) - .file-actions.hidden-xs - = render "actions" + = render "projects/blob/header", blob: blob = render blob.to_partial_path(@project), blob: blob diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml new file mode 100644 index 00000000000..deeeae3d64a --- /dev/null +++ b/app/views/projects/blob/_header.html.haml @@ -0,0 +1,39 @@ +.js-file-title.file-title-flex-parent + .file-header-content + = blob_icon blob.mode, blob.name + + %strong.file-title-name + = blob.name + + = copy_file_path_button(blob.path) + + %small + = number_to_human_size(blob_size(blob)) + + .file-actions.hidden-xs + .btn-group{ role: "group" }< + = copy_blob_content_button(blob) if blob_text_viewable?(blob) + = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id)) + = view_on_environment_button(@commit.sha, @path, @environment) if @environment + + .btn-group{ role: "group" }< + -# only show normal/blame view links for text files + - if blob_text_viewable?(blob) + - if current_page? namespace_project_blame_path(@project.namespace, @project, @id) + = link_to 'Normal View', namespace_project_blob_path(@project.namespace, @project, @id), + class: 'btn btn-sm' + - else + = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), + class: 'btn btn-sm js-blob-blame-link' unless blob.empty? + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), + class: 'btn btn-sm' + + = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, + tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' + + - if current_user + .btn-group{ role: "group" }< + = edit_blob_link if blob_text_viewable?(blob) + = replace_blob_link + = delete_blob_link diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml index f864702d862..ea3cecb86a9 100644 --- a/app/views/projects/blob/_image.html.haml +++ b/app/views/projects/blob/_image.html.haml @@ -9,7 +9,7 @@ - else .nothing-here-block The SVG could not be displayed as it is too large, you can - #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank')} + #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')} instead. - else %img{ src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path)), alt: "#{blob.name}" } diff --git a/app/views/projects/blob/_text.html.haml b/app/views/projects/blob/_text.html.haml index 58524418a67..7b16d266982 100644 --- a/app/views/projects/blob/_text.html.haml +++ b/app/views/projects/blob/_text.html.haml @@ -3,17 +3,17 @@ .nothing-here-block File too large, you can = succeed '.' do - = link_to 'view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank' + = link_to 'view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer' - else - blob.load_all_data!(@repository) - - if markup?(blob.name) - .file-content.wiki - = render_markup(blob.name, blob.data) + - if blob.empty? + .file-content.code + .nothing-here-block Empty file - else - - if blob.empty? - .file-content.code - .nothing-here-block Empty file + - if markup?(blob.name) + .file-content.wiki + = render_markup(blob.name, blob.data) - else = render 'shared/file_highlight', blob: blob, repository: @repository diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 8853801016b..3bcddcb37f1 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -9,7 +9,7 @@ - if @conflict .alert.alert-danger Someone edited the file the same time you did. Please check out - = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank" + = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank", rel: 'noopener noreferrer' and make sure your changes will not unintentionally remove theirs. .file-editor diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index fa463edd526..added3f669b 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -10,6 +10,7 @@ %script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board" %script#js-board-list-template{ type: "text/x-template" }= render "projects/boards/components/board_list" + %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal = render "projects/issues/head" diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml deleted file mode 100644 index 0af40ddf8fe..00000000000 --- a/app/views/projects/boards/components/_blank_state.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -%board-blank-state{ "inline-template" => true, - "v-if" => 'list.id == "blank"' } - .board-blank-state - %p - Add the following default lists to your Issue Board with one click: - %ul.board-blank-state-list - %li{ "v-for" => "label in predefinedLabels" } - %span.label-color{ ":style" => "{ backgroundColor: label.color } " } - {{ label.title }} - %p - Starting out with the default set of lists will get you right on the way to making the most of your board. - %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" } - Add default lists - %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" } - Nevermind, I'll use my own diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index 72bce4049de..0bca6a786cb 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -32,4 +32,4 @@ ":root-path" => "rootPath", "ref" => "board-list" } - if can?(current_user, :admin_list, @project) - = render "projects/boards/components/blank_state" + %board-blank-state{ "v-if" => 'list.id == "blank"' } diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index ae63f8184df..9eb610ba9c0 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -27,7 +27,7 @@ = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", method: :post, title: "Compare" do Compare - = render 'projects/buttons/download', project: @project, ref: branch.name + = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name] - if can?(current_user, :push_code, @project) = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index b560ed21f1d..d90d4a27cd6 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -1,3 +1,5 @@ +- pipeline = local_assigns.fetch(:pipeline) { project.pipelines.latest_successful_for(ref) } + - if !project.empty_repo? && can?(current_user, :download_code, project) .project-action-button.dropdown.inline> %button.btn{ 'data-toggle' => 'dropdown' } @@ -24,7 +26,6 @@ %i.fa.fa-download %span Download tar - - pipeline = project.pipelines.latest_successful_for(ref) - if pipeline - artifacts = pipeline.builds.latest.with_artifacts - if artifacts.any? diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml index 5d9a776da89..a5a9e4d0621 100644 --- a/app/views/projects/buttons/_koding.html.haml +++ b/app/views/projects/buttons/_koding.html.haml @@ -1,3 +1,3 @@ - if koding_enabled? && current_user && @repository.koding_yml && can_push_branch?(@project, @project.default_branch) - = link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank' do + = link_to koding_project_url(@project), class: 'btn project-action-button inline', target: '_blank', rel: 'noopener noreferrer' do Run in IDE (Koding) diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index d001e01609a..a0a292d0508 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -63,15 +63,15 @@ - if @commit.status .well-segment.pipeline-info - %div{ class: "icon-container ci-status-icon-#{@commit.status}" } - = link_to namespace_project_pipeline_path(@project.namespace, @project, @commit.pipelines.last.id) do + .status-icon-container{ class: "ci-status-icon-#{@commit.status}" } + = link_to namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id) do = ci_icon_for_status(@commit.status) Pipeline - = link_to "##{@commit.pipelines.last.id}", namespace_project_pipeline_path(@project.namespace, @project, @commit.pipelines.last.id), class: "monospace" - for - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace" - %span.ci-status-label - = ci_label_for_status(@commit.status) + = link_to "##{@commit.latest_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id), class: "monospace" + = ci_label_for_status(@commit.status) + - if @commit.latest_pipeline.stages.any? + .mr-widget-pipeline-graph + = render 'shared/mini_pipeline_graph', pipeline: @commit.latest_pipeline, klass: 'js-commit-pipeline-graph' in = time_interval_in_words @commit.pipelines.total_duration diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml index c8f0b547f80..9007f2c24ba 100644 --- a/app/views/projects/cycle_analytics/_overview.html.haml +++ b/app/views/projects/cycle_analytics/_overview.html.haml @@ -9,7 +9,7 @@ Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. To set up CA, you must first define a production environment by setting up your CI and then deploy to production. %p - %a.btn{ href: help_page_path('user/project/cycle_analytics'), target: "_blank" } Read more + %a.btn{ href: help_page_path('user/project/cycle_analytics'), target: '_blank' } Read more .col-md-6.overview-image %span.overview-icon = custom_icon ('icon_cycle_analytics_overview') diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index f809c52c367..7d6b3701f95 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -2,8 +2,11 @@ - if defined?(blob) && blob && diff_file.submodule? %span = icon('archive fw') - %span + + %strong.file-title-name = submodule_link(blob, diff_commit.id, project.repository) + + = copy_file_path_button(blob.path) - else = conditional_link_to url.present?, url do = blob_icon diff_file.b_mode, diff_file.file_path @@ -21,7 +24,7 @@ - if diff_file.deleted_file deleted - = clipboard_button(clipboard_text: diff_file.new_path, class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') + = copy_file_path_button(diff_file.new_path) - if diff_file.mode_changed? %small diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 58c085cdb9d..85e442e115c 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -5,6 +5,7 @@ = render 'shared/no_ssh' = render 'shared/no_password' += render "projects/head" = render "home_panel" .row-content-block.second-block.center diff --git a/app/views/projects/environments/_external_url.html.haml b/app/views/projects/environments/_external_url.html.haml index 4c8fe1c271b..bf0f1819073 100644 --- a/app/views/projects/environments/_external_url.html.haml +++ b/app/views/projects/environments/_external_url.html.haml @@ -1,3 +1,3 @@ - if environment.external_url && can?(current_user, :read_environment, environment) - = link_to environment.external_url, target: '_blank', class: 'btn external-url' do + = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url' do = icon('external-link') diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index f8e94ca98ae..b8c1782f050 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -1,5 +1,8 @@ - @no_container = true - page_title "Metrics for environment", @environment.name +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_d3') + = page_specific_javascript_bundle_tag('monitoring') = render "projects/pipelines/head" %div{ class: container_class } diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 7b7d7b1e00e..f3a429d12d9 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -19,15 +19,14 @@ .nav-controls = link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do = icon('rss') - - if can? current_user, :create_issue, @project - = link_to new_namespace_project_issue_path(@project.namespace, - @project, - issue: { assignee_id: issues_finder.assignee.try(:id), - milestone_id: issues_finder.milestones.first.try(:id) }), - class: "btn btn-new", - title: "New Issue", - id: "new_issue_link" do - New Issue + = link_to new_namespace_project_issue_path(@project.namespace, + @project, + issue: { assignee_id: issues_finder.assignee.try(:id), + milestone_id: issues_finder.milestones.first.try(:id) }), + class: "btn btn-new", + title: "New Issue", + id: "new_issue_link" do + New Issue = render 'shared/issuable/search_bar', type: :issues .issues-holder diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index e6a4cf929d2..d665c9125b3 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -20,37 +20,34 @@ = confidential_icon(@issue) = issuable_meta(@issue, @project, "Issue") - - if can?(current_user, :create_issue, @project) || can?(current_user, :update_issue, @issue) - .issuable-actions - .clearfix.issue-btn-group.dropdown - %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } - Options - = icon('caret-down') - .dropdown-menu.dropdown-menu-align-right.hidden-lg - %ul - - if can?(current_user, :create_issue, @project) - %li - = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' - - if can?(current_user, :update_issue, @issue) - %li - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, 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'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - %li - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) - - if @issue.submittable_as_spam_by?(current_user) - %li - = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' - - - if can?(current_user, :create_issue, @project) - = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do - New issue - - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + .issuable-actions + .clearfix.issue-btn-group.dropdown + %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } + Options + = icon('caret-down') + .dropdown-menu.dropdown-menu-align-right.hidden-lg + %ul + %li + = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' + - if can?(current_user, :update_issue, @issue) + %li + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, 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'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + %li + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) - if @issue.submittable_as_spam_by?(current_user) - = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' + %li + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + + = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do + New issue + - if can?(current_user, :update_issue, @issue) + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + - if @issue.submittable_as_spam_by?(current_user) + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' .issue-details.issuable-details .detail-page-description.content-block{ class: ('hide-bottom-border' unless @issue.description.present? ) } diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index c8f097c69da..6682a85ffa6 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -16,7 +16,7 @@ .pull-right - if @merge_request.source_branch_exists? - if koding_enabled? && @repository.koding_yml - = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank' do + = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank', rel: 'noopener noreferrer' do Run in IDE (Koding) = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do Check out branch diff --git a/app/views/projects/merge_requests/show/_how_to_merge.html.haml b/app/views/projects/merge_requests/show/_how_to_merge.html.haml index 93ed4b68e0e..cde0ce08e14 100644 --- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/show/_how_to_merge.html.haml @@ -49,7 +49,7 @@ %strong Tip: = succeed '.' do You can also checkout merge requests locally by - = link_to 'following these guidelines', help_page_path('user/project/merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank' + = link_to 'following these guidelines', help_page_path('user/project/merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer' :javascript $(function(){ diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index 11f41e75e63..55b0b837c6d 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -5,7 +5,7 @@ %div{ class: container_class } %h3.page-title - Edit Milestone ##{@milestone.iid} + Edit Milestone #{@milestone.to_reference} %hr diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b4dde2c86c9..d16f49bd33a 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -20,7 +20,7 @@ .header-text-content %span.identifier %strong - Milestone %#{@milestone.iid} + Milestone #{@milestone.to_reference} - if @milestone.due_date || @milestone.start_date = milestone_date_range(@milestone) .milestone-buttons diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 2a98bba05ee..d129da943f8 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -1,5 +1,6 @@ - page_title 'New Project' - header_title "Projects", dashboard_projects_path +- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility .project-edit-container .project-edit-errors @@ -95,7 +96,7 @@ = f.label :visibility_level, class: 'label-light' do Visibility Level = link_to icon('question-circle'), help_page_path("public_access/public_access") - = render 'shared/visibility_level', f: f, visibility_level: default_project_visibility, can_change_visibility_level: true, form_model: @project, with_label: false + = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false = f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4 = link_to 'Cancel', dashboard_projects_path, class: 'btn btn-cancel' diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 3a323d94cc2..2fb88297fb3 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -4,13 +4,13 @@ %ul.list-unstyled.indent-list %li 1. - = link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do + = link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do Enable custom slash commands = icon('external-link') on your Mattermost installation %li 2. - = link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noreferrer noopener nofollow' do + = link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noopener noreferrer nofollow' do Add a slash command = icon('external-link') in your Mattermost team with these options: diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index a04fd5035a6..2a1b9d4c465 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -4,7 +4,7 @@ %p This service allows users to perform common operations on this project by entering slash commands in Mattermost. - = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank', ref: 'noreferrer nofollow noopener' do + = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do View documentation = icon('external-link') %p.inline diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 0d973a20d4c..078b7be6865 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -5,7 +5,7 @@ %p This service allows users to perform common operations on this project by entering slash commands in Slack. - = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank', ref: 'noreferrer nofollow noopener' do + = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do View documentation = icon('external-link') %p.inline @@ -57,7 +57,7 @@ = label_tag nil, 'Customize icon', class: 'col-sm-2 col-xs-12 control-label' .col-sm-10.col-xs-12.text-block = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36) - = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank') + = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer') .form-group = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label' diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 6b3d7d4008b..e35385f4cab 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -4,13 +4,7 @@ .project-snippets %article.file-holder.snippet-file-content - .js-file-title.file-title - = blob_icon 0, @snippet.file_name - = @snippet.file_name - .file-actions - = clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{@snippet.id}']", class: "btn btn-sm") - = link_to 'Raw', raw_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-sm", target: "_blank" - = render 'shared/snippets/blob' + = render 'shared/snippets/blob', raw_path: raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 8ef069b9e05..dffe908e85a 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -23,7 +23,7 @@ = markdown_field(release, :description) .row-fixed-content.controls - = render 'projects/buttons/download', project: @project, ref: tag.name + = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :push_code, @project) = link_to edit_namespace_project_tag_release_path(@project.namespace, @project, tag.name), class: 'btn has-tooltip', title: "Edit release notes", data: { container: "body" } do diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml index 763c2fea39b..5211ade1a5f 100644 --- a/app/views/projects/wikis/_main_links.html.haml +++ b/app/views/projects/wikis/_main_links.html.haml @@ -4,6 +4,6 @@ New Page = link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do Page History - - if can?(current_user, :create_wiki, @project) + - if can?(current_user, :create_wiki, @project) && @page.latest? = link_to namespace_project_wiki_edit_path(@project.namespace, @project, @page), class: "btn" do Edit diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml index 0ce0d759e86..a212c714826 100644 --- a/app/views/shared/_sort_dropdown.html.haml +++ b/app/views/shared/_sort_dropdown.html.haml @@ -1,6 +1,5 @@ .dropdown.inline.prepend-left-10 %button.dropdown-toggle{ type: 'button', data: {toggle: 'dropdown' } } - %span.light - if @sort.present? = sort_options_hash[@sort] - else @@ -10,6 +9,8 @@ %li = link_to page_filter_path(sort: sort_value_priority, label: true) do = sort_title_priority + = link_to page_filter_path(sort: sort_value_label_priority, label: true) do + = sort_title_label_priority = link_to page_filter_path(sort: sort_value_recently_created, label: true) do = sort_title_recently_created = link_to page_filter_path(sort: sort_value_oldest_created, label: true) do diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index e2033654018..7a7e3d46796 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -16,7 +16,6 @@ Also, issues are searchable and filterable. - if project_select_button = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue' - - else - = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' - else - %h4.text-center There are no issues to show. + %h4 There are no issues to show. + = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index ba5c2dae09d..00fb77bdb3b 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -5,7 +5,7 @@ .col-xs-12.col-sm-6 .text-content %h4 Labels can be applied to issues and merge requests to categorize them. - %p You can also star label to make it a priority label. + %p You can also star a label to make it a priority label. - if can?(current_user, :admin_label, @project) = link_to 'New label', new_namespace_project_label_path(@project.namespace, @project), class: 'btn btn-new', title: 'New label', id: 'new_label_link' = link_to 'Generate a default set of labels', generate_namespace_project_labels_path(@project.namespace, @project), method: :post, class: 'btn btn-success btn-inverted', title: 'Generate a default set of labels', id: 'generate_labels_link' diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 60ca23ef680..a95020a9be8 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -1,5 +1,6 @@ - group_member = local_assigns[:group_member] - full_name = true unless local_assigns[:full_name] == false +- group_name = full_name ? group.full_name : group.name - css_class = '' unless local_assigns[:css_class] - css_class += " no-description" if group.description.blank? @@ -28,11 +29,7 @@ .avatar-container.s40 = image_tag group_icon(group), class: "avatar s40 hidden-xs" .title - = link_to group, class: 'group-name' do - - if full_name - = group.full_name - - else - = group.name + = link_to group_name, group, class: 'group-name' - if group_member as diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index f0bad69a989..847a86e2e68 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -24,7 +24,7 @@ placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) .filter-item.inline.milestone-filter - = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true + = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true .filter-item.inline.labels-filter = render "shared/issuable/label_dropdown", selected: finder.labels.select(:title).uniq, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" } diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 0b0f2c9cd1a..17107f55a2d 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -8,7 +8,7 @@ .alert.alert-danger Someone edited the #{issuable.class.model_name.human.downcase} the same time you did. Please check out - = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), target: "_blank" + = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), target: "_blank", rel: 'noopener noreferrer' and make sure your changes will not unintentionally remove theirs .form-group diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index 415361f8fbf..f0d50828e2a 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -6,7 +6,7 @@ - if selected.present? || params[:milestone_title].present? = hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id) = dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", - placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do + placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do - if project %ul.dropdown-footer-list - if can? current_user, :admin_milestone, project diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f1730b1791c..b58640c3ef0 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -1,7 +1,8 @@ - type = local_assigns.fetch(:type) +- block_css_class = type != :boards_modal ? 'row-content-block second-block' : '' .issues-filters - .issues-details-filters.row-content-block.second-block.filtered-search-block + .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal } = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do - if params[:search].present? = hidden_field_tag :search, params[:search] @@ -14,7 +15,7 @@ .scroll-container %ul.tokens-container.list-unstyled %li.input-token - %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: 'filtered-search', 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } + %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } = icon('filter') %button.clear-search.hidden{ type: 'button' } = icon('times') @@ -68,12 +69,15 @@ %li.filter-dropdown-item{ data: { value: 'upcoming' } } %button.btn.btn-link Upcoming + %li.filter-dropdown-item{ 'data-value' => 'started' } + %button.btn.btn-link + Started %li.divider %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item %button.btn.btn-link.js-data-value {{title}} - #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label' } } + #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link @@ -97,7 +101,7 @@ = render partial: "shared/issuable/label_page_create" = dropdown_loading #js-add-issues-btn.prepend-left-10 - - else + - elsif type != :boards_modal = render 'shared/sort_dropdown' - if @bulk_edit @@ -130,19 +134,20 @@ .filter-item.inline.update-issues-btn = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" -:javascript - new UsersSelect(); - new LabelsSelect(); - new MilestoneSelect(); - new IssueStatusSelect(); - new SubscriptionSelect(); +- unless type === :boards_modal + :javascript + new UsersSelect(); + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); + new SubscriptionSelect(); - $(document).off('page:restore').on('page:restore', function (event) { - if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(); - } - Issuable.init(); - new gl.IssuableBulkActions({ - prefixId: 'issue_', + $(document).off('page:restore').on('page:restore', function (event) { + if (gl.FilteredSearchManager) { + new gl.FilteredSearchManager(); + } + Issuable.init(); + new gl.IssuableBulkActions({ + prefixId: 'issue_', + }); }); - }); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 048fc488207..25a4aec0a38 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -30,7 +30,7 @@ = icon('user', 'aria-hidden': 'true') .title.hide-collapsed Assignee - = icon('spinner spin', class: 'block-loading', 'aria-hidden': 'true') + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' .value.hide-collapsed @@ -64,7 +64,7 @@ None .title.hide-collapsed Milestone - = icon('spinner spin', class: 'block-loading', 'aria-hidden': 'true') + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' .value.hide-collapsed @@ -91,7 +91,7 @@ = issuable.due_date.try(:to_s, :medium) || 'None' .title.hide-collapsed Due date - = icon('spinner spin', class: 'block-loading', 'aria-hidden': 'true') + = 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' .value.hide-collapsed @@ -121,12 +121,12 @@ - selected_labels = issuable.labels .block.labels .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body" } } - = icon('tags', 'aria-hidden': 'true') + = icon('tags', class: 'hidden', 'aria-hidden': 'true') %span = selected_labels.size .title.hide-collapsed Labels - = icon('spinner spin', class: 'block-loading', 'aria-hidden': 'true') + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' .value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 7a21f19ded4..9dbfedb84f1 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -21,7 +21,7 @@ = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } .issuable-form-select-holder - = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" + = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" .form-group - has_labels = @labels && @labels.any? = form.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}" diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 7e9fb7bb4d3..df21857e1ad 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -6,17 +6,16 @@ - css_class = '' unless local_assigns[:css_class] - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description -- cache_key = [project.namespace, project, controller.controller_name, controller.action_name, current_application_settings, 'v2.3'] -- cache_key.push(project.commit&.sha, project.commit&.status) +- cache_key = project_list_cache_key(project) %li.project-row{ class: css_class } = cache(cache_key) do .controls - if project.archived %span.label.label-warning archived - - if project.commit.try(:status) + - if project.pipeline_status.has_status? %span - = render_commit_status(project.commit) + = render_project_pipeline_status(project.pipeline_status) - if forks %span = icon('code-fork') diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index ad5c0c2d8c8..74f71e6cbd1 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -1,7 +1,25 @@ -- unless @snippet.content.empty? +.js-file-title.file-title-flex-parent + .file-header-content + = blob_icon @snippet.mode, @snippet.path + + %strong.file-title-name + = @snippet.path + + = copy_file_path_button(@snippet.path) + + .file-actions.hidden-xs + .btn-group{ role: "group" }< + = copy_blob_content_button(@snippet) + = open_raw_file_button(raw_path) + + - if defined?(download_path) && download_path + = link_to icon('download'), download_path, class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' } + +- if @snippet.content.empty? + .file-content.code + .nothing-here-block Empty file +- else - if markup?(@snippet.file_name) - %textarea.markdown-snippet-copy.blob-content{ data: { blob_id: @snippet.id } } - = @snippet.content .file-content.wiki - if gitlab_markdown?(@snippet.file_name) = preserve(markdown_field(@snippet, :content)) @@ -9,6 +27,3 @@ = render_markup(@snippet.file_name, @snippet.content) - else = render 'shared/file_highlight', blob: @snippet -- else - .file-content.code - .nothing-here-block Empty file diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index e7f7db73223..0296597b294 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -3,7 +3,7 @@ = page_specific_javascript_bundle_tag('snippet') .snippet-form-holder - = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input" } do |f| + = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit" } do |f| = form_errors(@snippet) .form-group diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 970afbe6b64..da9fb755a36 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -3,13 +3,7 @@ = render 'shared/snippets/header' %article.file-holder.snippet-file-content - .js-file-title.file-title - = blob_icon 0, @snippet.file_name - = @snippet.file_name - .file-actions - = clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{@snippet.id}']", class: "btn btn-sm") - = link_to 'Raw', raw_snippet_path(@snippet), class: "btn btn-sm", target: "_blank" - = link_to 'Download', download_snippet_path(@snippet), class: "btn btn-sm" - = render 'shared/snippets/blob' + = render 'shared/snippets/blob', raw_path: raw_snippet_path(@snippet), download_path: download_snippet_path(@snippet) -= render 'award_emoji/awards_block', awardable: @snippet, inline: true +.row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 76cd330e80a..dc9a3b0d0df 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -33,7 +33,7 @@ .profile-header .avatar-holder - = link_to avatar_icon(@user, 400), target: '_blank' do + = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon(@user, 90), class: "avatar s90", alt: '' .user-info diff --git a/app/workers/build_email_worker.rb b/app/workers/build_email_worker.rb deleted file mode 100644 index 5fdb1f2baa0..00000000000 --- a/app/workers/build_email_worker.rb +++ /dev/null @@ -1,20 +0,0 @@ -class BuildEmailWorker - include Sidekiq::Worker - include BuildQueue - - def perform(build_id, recipients, push_data) - recipients.each do |recipient| - begin - case push_data['build_status'] - when 'success' - Notify.build_success_email(build_id, recipient).deliver_now - when 'failed' - Notify.build_fail_email(build_id, recipient).deliver_now - end - # These are input errors and won't be corrected even if Sidekiq retries - rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e - logger.info("Failed to send e-mail for project '#{push_data['project_name']}' to #{recipient}: #{e}") - end - end - end -end |