diff options
Diffstat (limited to 'app')
146 files changed, 2304 insertions, 1932 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/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..1b66c8b922d 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; @@ -72,19 +70,13 @@ require('./empty_state'); }, }, 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) { 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/dispatcher.js b/app/assets/javascripts/dispatcher.js index 6d8174e199e..db1a2848d8d 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -37,6 +37,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 +201,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': @@ -368,6 +372,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 +382,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/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_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_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..7ace51748aa 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) { @@ -132,7 +135,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/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js index 66cc270ab4d..94a4f24f1d7 100644 --- a/app/assets/javascripts/merge_request_widget.js +++ b/app/assets/javascripts/merge_request_widget.js @@ -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..02ff6f5682c 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'); } 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/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 f1b80e45444..7ac10086a55 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_store.js +++ b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js @@ -1,31 +1,46 @@ /* eslint-disable no-underscore-dangle*/ -/** - * Pipelines' Store for commits view. - * - * Used to store the Pipelines rendered in the commit view in the pipelines table. - */ -require('../../vue_realtime_listener'); - -class PipelinesStore { +import '../../vue_realtime_listener'; + +export default class PipelinesStore { constructor() { this.state = {}; + this.state.pipelines = []; + this.state.count = {}; + this.state.pageInfo = {}; } storePipelines(pipelines = []) { this.state.pipelines = pipelines; + } - return pipelines; + storeCount(count = {}) { + this.state.count = count; + } + + 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) => { @@ -44,5 +59,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/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..2ebeaf9a40d 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -144,7 +144,7 @@ .scroll-container { display: -webkit-flex; display: flex; - overflow-x: scroll; + overflow-x: auto; white-space: nowrap; width: 100%; } 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/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/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/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..498690e8f11 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 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/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/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/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_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/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 7f32c1b5300..0b0c6a07efd 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', 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/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/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/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..4f5adf623f2 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -99,8 +99,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/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8a5a9aa4adb..d1009f88549 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -22,6 +22,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 +115,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 +335,7 @@ module Ci when 'manual' then block end end + refresh_build_status_cache end def predefined_variables @@ -369,6 +377,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/commit.rb b/app/models/commit.rb index d5ecbe76c82..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 ||= {} diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3cf4c67d7e7..91f4eb13ecc 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -144,7 +144,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 +155,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 +186,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 +261,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/issue.rb b/app/models/issue.rb index dba9398a43c..1427fdc31a4 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -58,7 +58,13 @@ class Issue < ActiveRecord::Base 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..4759829a15c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -523,7 +523,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 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..2ffaaac93f3 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1209,6 +1209,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/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/wiki_page.rb b/app/models/wiki_page.rb index 2caebb496db..465c4d903ac 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -149,6 +149,12 @@ class WikiPage end # Returns boolean True or False if this instance + # is the latest commit version of the page. + def latest? + !historical? + end + + # Returns boolean True or False if this instance # has been fully saved to disk or not. def persisted? @persisted == true 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_service.rb b/app/services/notification_service.rb index d12692ecc90..fdaba9b95fb 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -217,7 +217,7 @@ class NotificationService 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.delete(note.author) recipients = recipients.uniq notify_method = "note_#{note.to_ability_name}_email".to_sym @@ -327,9 +327,8 @@ class NotificationService recipients ||= 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 @@ -628,7 +627,7 @@ class NotificationService 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.delete(current_user) if skip_current_user recipients.uniq end @@ -637,7 +636,7 @@ class NotificationService 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.delete(current_user) recipients.uniq end 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/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/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/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/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/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/_text.html.haml b/app/views/projects/blob/_text.html.haml index 58524418a67..b1e1be49de9 100644 --- a/app/views/projects/blob/_text.html.haml +++ b/app/views/projects/blob/_text.html.haml @@ -8,12 +8,12 @@ - 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/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/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/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/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/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..367aa550a78 100644 --- a/app/views/shared/_sort_dropdown.html.haml +++ b/app/views/shared/_sort_dropdown.html.haml @@ -10,6 +10,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/_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/_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/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 |