diff options
Diffstat (limited to 'app')
302 files changed, 3122 insertions, 1778 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index b4a8c827d7f..84bbe90f3b1 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -11,7 +11,7 @@ licensePath: "/api/:version/templates/licenses/:key", gitignorePath: "/api/:version/templates/gitignores/:key", gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key", - dockerfilePath: "/api/:version/dockerfiles/:key", + dockerfilePath: "/api/:version/templates/dockerfiles/:key", issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", group: function(group_id, callback) { var url = Api.buildUrl(Api.groupPath) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 637fca4d4da..4b5c9686cab 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,7 +10,6 @@ function requireAll(context) { return context.keys().map(context); } window.$ = window.jQuery = require('jquery'); require('jquery-ui/ui/autocomplete'); -require('jquery-ui/ui/datepicker'); require('jquery-ui/ui/draggable'); require('jquery-ui/ui/effect-highlight'); require('jquery-ui/ui/sortable'); @@ -21,7 +20,7 @@ require('vendor/jquery.waitforimages'); require('vendor/jquery.caret'); require('vendor/jquery.atwho'); require('vendor/jquery.scrollTo'); -window.Cookies = require('vendor/js.cookie'); +window.Cookies = require('js-cookie'); require('./autosave'); require('bootstrap/js/affix'); require('bootstrap/js/alert'); @@ -35,8 +34,10 @@ require('bootstrap/js/transition'); require('bootstrap/js/tooltip'); require('bootstrap/js/popover'); require('select2/select2.js'); +window.Pikaday = require('pikaday'); window._ = require('underscore'); window.Dropzone = require('dropzone'); +window.Sortable = require('vendor/Sortable'); require('mousetrap'); require('mousetrap/plugins/pause/mousetrap-pause'); require('./shortcuts'); @@ -55,8 +56,7 @@ requireAll(require.context('./u2f', false, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('./droplab', false, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('.', false, /^\.\/(?!application\.js).*\.(js|es6)$/)); require('vendor/fuzzaldrin-plus'); -window.ES6Promise = require('vendor/es6-promise.auto'); -window.ES6Promise.polyfill(); +require('es6-promise').polyfill(); (function () { document.addEventListener('beforeunload', function () { @@ -247,5 +247,7 @@ window.ES6Promise.polyfill(); new Aside(); // bind sidebar events new gl.Sidebar(); + + gl.utils.initTimeagoTimeout(); }); }).call(this); diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 index e3241974e59..8f30900198e 100644 --- a/app/assets/javascripts/boards/boards_bundle.js.es6 +++ b/app/assets/javascripts/boards/boards_bundle.js.es6 @@ -6,7 +6,6 @@ function requireAll(context) { return context.keys().map(context); } window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -window.Sortable = require('vendor/Sortable'); requireAll(require.context('./models', true, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('./stores', true, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('./services', true, /^\.\/.*\.(js|es6)$/)); @@ -16,7 +15,7 @@ require('./components/board'); require('./components/board_sidebar'); require('./components/new_list_dropdown'); require('./components/modal/index'); -require('./vue_resource_interceptor'); +require('../vue_shared/vue_resource_interceptor'); $(() => { const $boardApp = document.getElementById('board-app'); diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js.es6 b/app/assets/javascripts/boards/filters/due_date_filters.js.es6 index 7e192e90fe6..03425bb145b 100644 --- a/app/assets/javascripts/boards/filters/due_date_filters.js.es6 +++ b/app/assets/javascripts/boards/filters/due_date_filters.js.es6 @@ -1,6 +1,7 @@ /* global Vue */ +/* global dateFormat */ Vue.filter('due-date', (value) => { const date = new Date(value); - return $.datepicker.formatDate('M d, yy', date); + return dateFormat(date, 'mmm d, yyyy', true); }); diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 deleted file mode 100644 index 54c2b4ad369..00000000000 --- a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars */ -/* global Vue */ - -Vue.http.interceptors.push((request, next) => { - Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; - - next(function (response) { - Vue.activeResources -= 1; - }); -}); diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 0152be88b48..c5a962dd199 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -67,16 +67,8 @@ Build.prototype.initSidebar = function() { this.$sidebar = $('.js-build-sidebar'); - this.sidebarTranslationLimits = { - min: $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight() - }; - this.sidebarTranslationLimits.max = this.sidebarTranslationLimits.min + $('.scrolling-tabs-container').outerHeight(); - this.$sidebar.css({ - top: this.sidebarTranslationLimits.max - }); this.$sidebar.niceScroll(); this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); - this.$document.off('scroll.translateSidebar').on('scroll.translateSidebar', this.translateSidebar.bind(this)); }; Build.prototype.location = function() { @@ -231,14 +223,6 @@ return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; }; - Build.prototype.translateSidebar = function(e) { - var newPosition = this.sidebarTranslationLimits.max - (document.body.scrollTop || document.documentElement.scrollTop); - if (newPosition < this.sidebarTranslationLimits.min) newPosition = this.sidebarTranslationLimits.min; - this.$sidebar.css({ - top: newPosition - }); - }; - Build.prototype.toggleSidebar = function(shouldHide) { var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) @@ -285,7 +269,7 @@ e.preventDefault(); $currentTarget = $(e.currentTarget); $.scrollTo($currentTarget.attr('href'), { - offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight()) + offset: 0 }); }; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 new file mode 100644 index 00000000000..fbfec7743c7 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 @@ -0,0 +1,26 @@ +/* eslint-disable no-new, no-param-reassign */ +/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ + +window.Vue = require('vue'); +require('./pipelines_table'); +/** + * Commits View > Pipelines Tab > Pipelines Table. + * Merge Request View > Pipelines Tab > Pipelines Table. + * + * Renders Pipelines table in pipelines tab in the commits show view. + * Renders Pipelines table in pipelines tab in the merge request show view. + */ + +$(() => { + window.gl = window.gl || {}; + gl.commits = gl.commits || {}; + gl.commits.pipelines = gl.commits.pipelines || {}; + + if (gl.commits.PipelinesTableBundle) { + gl.commits.PipelinesTableBundle.$destroy(true); + } + + gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView({ + el: document.querySelector('#commit-pipeline-table-view'), + }); +}); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 new file mode 100644 index 00000000000..8ae98f9bf97 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 @@ -0,0 +1,44 @@ +/* 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_store.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 new file mode 100644 index 00000000000..f1b41911b73 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 @@ -0,0 +1,50 @@ +/* eslint-disable no-underscore-dangle*/ +/** + * Pipelines' Store for commits view. + * + * Used to store the Pipelines rendered in the commit view in the pipelines table. + */ + +class PipelinesStore { + constructor() { + this.state = {}; + this.state.pipelines = []; + } + + storePipelines(pipelines = []) { + this.state.pipelines = pipelines; + + return pipelines; + } + + /** + * 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. + * + */ + startTimeAgoLoops() { + const startTimeLoops = () => { + this.timeLoopInterval = setInterval(() => { + this.$children[0].$children.reduce((acc, component) => { + const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0]; + acc.push(timeAgoComponent); + return acc; + }, []).forEach(e => e.changeTime()); + }, 10000); + }; + + startTimeLoops(); + + const removeIntervals = () => clearInterval(this.timeLoopInterval); + const startIntervals = () => startTimeLoops(); + + gl.VueRealtimeListener(removeIntervals, startIntervals); + } +} + +window.gl = window.gl || {}; +gl.commits = gl.commits || {}; +gl.commits.pipelines = gl.commits.pipelines || {}; +gl.commits.pipelines.PipelinesStore = PipelinesStore; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 new file mode 100644 index 00000000000..ce0dbd4d56b --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 @@ -0,0 +1,107 @@ +/* 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('../../vue_realtime_listener/index'); +require('./pipelines_service'); +require('./pipelines_store'); + +/** + * + * Uses `pipelines-table-component` to render Pipelines table with an API call. + * Endpoint is provided in HTML and passed as `endpoint`. + * We need a store to store the received environemnts. + * We need a service to communicate with the server. + * + * Necessary SVG in the table are provided as props. This should be refactored + * 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 || {}; + + gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', { + + components: { + 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, + }, + + /** + * 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 svgsData = document.querySelector('.pipeline-svgs').dataset; + const store = new gl.commits.pipelines.PipelinesStore(); + + // Transform svgs DOMStringMap to a plain Object. + const svgsObject = gl.utils.DOMStringMapToObject(svgsData); + + return { + endpoint: pipelinesTableData.endpoint, + svgs: svgsObject, + store, + state: store.state, + isLoading: false, + }; + }, + + /** + * When the component is created the service to fetch the data will be + * initialized with the correct endpoint. + * + * 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. + * + */ + created() { + const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint); + + this.isLoading = true; + return pipelinesService.all() + .then(response => response.json()) + .then((json) => { + this.store.storePipelines(json); + this.store.startTimeAgoLoops.call(this, Vue); + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert'); + }); + }, + + template: ` + <div> + <div class="pipelines 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="table-holder pipelines" + v-if="!isLoading && state.pipelines.length > 0"> + <pipelines-table-component + :pipelines="state.pipelines" + :svgs="svgs"> + </pipelines-table-component> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/copy_as_gfm.js.es6 b/app/assets/javascripts/copy_as_gfm.js.es6 index 2bfe57b4100..4bd537a6f28 100644 --- a/app/assets/javascripts/copy_as_gfm.js.es6 +++ b/app/assets/javascripts/copy_as_gfm.js.es6 @@ -91,6 +91,9 @@ require('./lib/utils/common_utils'); }, }, SanitizationFilter: { + 'a[name]:not([href]):empty'(el, text) { + return el.outerHTML; + }, 'dl'(el, text) { let lines = text.trim().split('\n'); // Add two spaces to the front of subsequent list items lines, diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 index 513298ba4e7..8652479e7bf 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 @@ -13,6 +13,12 @@ <div> <div class="events-description"> {{ stage.description }} + <span v-if="items.length === 50" class="events-info pull-right"> + <i class="fa fa-warning has-tooltip" + title="Limited to showing 50 events at most" + data-placement="top"></i> + Showing 50 events + </span> </div> <ul class="stage-event-list"> <li v-for="commit in items" class="stage-event-item"> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 index c41c57c1dcd..dbdb01c8c68 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 @@ -3,7 +3,7 @@ /* global Flash */ window.Vue = require('vue'); -window.Cookies = require('vendor/js.cookie'); +window.Cookies = require('js-cookie'); function requireAll(context) { return context.keys().map(context); } requireAll(require.context('./svg', false, /^\.\/.*\.(js|es6)$/)); diff --git a/app/assets/javascripts/diff.js.es6 b/app/assets/javascripts/diff.js.es6 index c39e30fb7e0..ccccd0a36ff 100644 --- a/app/assets/javascripts/diff.js.es6 +++ b/app/assets/javascripts/diff.js.es6 @@ -76,7 +76,7 @@ require('./lib/utils/url_utility'); const diffFile = diffTitle.closest('.diff-file'); const nothingHereBlock = $('.nothing-here-block:visible', diffFile); if (nothingHereBlock.length) { - const clickTarget = $('.file-title, .click-to-expand', diffFile); + const clickTarget = $('.js-file-title, .click-to-expand', diffFile); diffFile.data('singleFileDiff').toggleDiff(clickTarget, () => { this.highlighSelectedLine(); if (cb) cb(); diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 index 2514459e65e..d948dff58ec 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 @@ -1,6 +1,6 @@ /* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, quotes, no-lonely-if, max-len */ -/* global Vue */ /* global CommentsStore */ +const Vue = require('vue'); (() => { const CommentAndResolveBtn = Vue.extend({ @@ -9,13 +9,11 @@ }, data() { return { - textareaIsEmpty: true + textareaIsEmpty: true, + discussion: {}, }; }, computed: { - discussion: function () { - return CommentsStore.state[this.discussionId]; - }, showButton: function () { if (this.discussion) { return this.discussion.isResolvable(); @@ -42,6 +40,9 @@ } } }, + created() { + this.discussion = CommentsStore.state[this.discussionId]; + }, mounted: function () { const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); this.textareaIsEmpty = $textarea.val() === ''; diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 index c3898873eaa..283dc330cad 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 @@ -1,7 +1,7 @@ /* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, guard-for-in, no-restricted-syntax, one-var, space-before-function-paren, no-lonely-if, no-continue, brace-style, max-len, quotes */ -/* global Vue */ /* global DiscussionMixins */ /* global CommentsStore */ +const Vue = require('vue'); (() => { const JumpToDiscussion = Vue.extend({ @@ -12,12 +12,10 @@ data: function () { return { discussions: CommentsStore.state, + discussion: {}, }; }, computed: { - discussion: function () { - return this.discussions[this.discussionId]; - }, allResolved: function () { return this.unresolvedDiscussionCount === 0; }, @@ -183,10 +181,13 @@ } $.scrollTo($target, { - offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight()) + offset: 0 }); } - } + }, + created() { + this.discussion = this.discussions[this.discussionId]; + }, }); Vue.component('jump-to-discussion', JumpToDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 index 5852b8bbdb7..d1873d6c7a2 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 @@ -1,8 +1,8 @@ /* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, no-new, max-len */ -/* global Vue */ /* global CommentsStore */ /* global ResolveService */ /* global Flash */ +const Vue = require('vue'); (() => { const ResolveBtn = Vue.extend({ @@ -10,14 +10,14 @@ noteId: Number, discussionId: String, resolved: Boolean, - projectPath: String, canResolve: Boolean, resolvedBy: String }, data: function () { return { discussions: CommentsStore.state, - loading: false + loading: false, + note: {}, }; }, watch: { @@ -30,13 +30,6 @@ discussion: function () { return this.discussions[this.discussionId]; }, - note: function () { - if (this.discussion) { - return this.discussion.getNote(this.noteId); - } else { - return undefined; - } - }, buttonText: function () { if (this.isResolved) { return `Resolved by ${this.resolvedByName}`; @@ -73,10 +66,10 @@ if (this.isResolved) { promise = ResolveService - .unresolve(this.projectPath, this.noteId); + .unresolve(this.noteId); } else { promise = ResolveService - .resolve(this.projectPath, this.noteId); + .resolve(this.noteId); } promise.then((response) => { @@ -106,6 +99,8 @@ }, created: function () { CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy); + + this.note = this.discussion.getNote(this.noteId); } }); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 index 72cdae812bc..de9367f2136 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 +++ b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 @@ -1,7 +1,7 @@ /* eslint-disable comma-dangle, object-shorthand, func-names, no-param-reassign */ -/* global Vue */ /* global DiscussionMixins */ /* global CommentsStore */ +const Vue = require('vue'); ((w) => { w.ResolveCount = Vue.extend({ diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 index ee5f62b2d9e..7c5fcd04d2d 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 @@ -1,25 +1,22 @@ /* eslint-disable object-shorthand, func-names, space-before-function-paren, comma-dangle, no-else-return, quotes, max-len */ -/* global Vue */ /* global CommentsStore */ /* global ResolveService */ +const Vue = require('vue'); + (() => { const ResolveDiscussionBtn = Vue.extend({ props: { discussionId: String, mergeRequestId: Number, - projectPath: String, canResolve: Boolean, }, data: function() { return { - discussions: CommentsStore.state + discussion: {}, }; }, computed: { - discussion: function () { - return this.discussions[this.discussionId]; - }, showButton: function () { if (this.discussion) { return this.discussion.isResolvable(); @@ -51,11 +48,13 @@ }, methods: { resolve: function () { - ResolveService.toggleResolveForDiscussion(this.projectPath, this.mergeRequestId, this.discussionId); + ResolveService.toggleResolveForDiscussion(this.mergeRequestId, this.discussionId); } }, created: function () { CommentsStore.createDiscussion(this.discussionId, this.canResolve); + + this.discussion = CommentsStore.state[this.discussionId]; } }); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 index f0edfb8aaf1..190461451d5 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 @@ -3,6 +3,7 @@ /* global ResolveCount */ function requireAll(context) { return context.keys().map(context); } +const Vue = require('vue'); requireAll(require.context('./models', false, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('./stores', false, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('./services', false, /^\.\/.*\.(js|es6)$/)); @@ -10,11 +11,14 @@ requireAll(require.context('./mixins', false, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('./components', false, /^\.\/.*\.(js|es6)$/)); $(() => { + const projectPath = document.querySelector('.merge-request').dataset.projectPath; const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn'; window.gl = window.gl || {}; window.gl.diffNoteApps = {}; + window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); + gl.diffNotesCompileComponents = () => { const $components = $(COMPONENT_SELECTOR).filter(function () { return $(this).closest('resolve-count').length !== 1; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js.es6 b/app/assets/javascripts/diff_notes/services/resolve.js.es6 index a52c476352d..090c454e9e4 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js.es6 +++ b/app/assets/javascripts/diff_notes/services/resolve.js.es6 @@ -1,45 +1,37 @@ /* eslint-disable class-methods-use-this, one-var, camelcase, no-new, comma-dangle, no-param-reassign, max-len */ -/* global Vue */ /* global Flash */ /* global CommentsStore */ -((w) => { - class ResolveServiceClass { - constructor() { - this.noteResource = Vue.resource('notes{/noteId}/resolve'); - this.discussionResource = Vue.resource('merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve'); - } +const Vue = window.Vue = require('vue'); +window.Vue.use(require('vue-resource')); +require('../../vue_shared/vue_resource_interceptor'); - setCSRF() { - Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken(); - } +(() => { + window.gl = window.gl || {}; - prepareRequest(root) { - this.setCSRF(); - Vue.http.options.root = root; + class ResolveServiceClass { + constructor(root) { + this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); + this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); } - resolve(projectPath, noteId) { - this.prepareRequest(projectPath); - + resolve(noteId) { return this.noteResource.save({ noteId }, {}); } - unresolve(projectPath, noteId) { - this.prepareRequest(projectPath); - + unresolve(noteId) { return this.noteResource.delete({ noteId }, {}); } - toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId) { + toggleResolveForDiscussion(mergeRequestId, discussionId) { const discussion = CommentsStore.state[discussionId]; const isResolved = discussion.isResolved(); let promise; if (isResolved) { - promise = this.unResolveAll(projectPath, mergeRequestId, discussionId); + promise = this.unResolveAll(mergeRequestId, discussionId); } else { - promise = this.resolveAll(projectPath, mergeRequestId, discussionId); + promise = this.resolveAll(mergeRequestId, discussionId); } promise.then((response) => { @@ -62,11 +54,9 @@ }); } - resolveAll(projectPath, mergeRequestId, discussionId) { + resolveAll(mergeRequestId, discussionId) { const discussion = CommentsStore.state[discussionId]; - this.prepareRequest(projectPath); - discussion.loading = true; return this.discussionResource.save({ @@ -75,11 +65,9 @@ }, {}); } - unResolveAll(projectPath, mergeRequestId, discussionId) { + unResolveAll(mergeRequestId, discussionId) { const discussion = CommentsStore.state[discussionId]; - this.prepareRequest(projectPath); - discussion.loading = true; return this.discussionResource.delete({ @@ -89,5 +77,5 @@ } } - w.ResolveService = new ResolveServiceClass(); -})(window); + gl.DiffNotesResolveServiceClass = ResolveServiceClass; +})(); diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index edec21e3b63..7eec2d39a9c 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -19,7 +19,6 @@ /* global UsersSelect */ /* global GroupAvatar */ /* global LineHighlighter */ -/* global ShortcutsBlob */ /* global ProjectFork */ /* global BuildArtifacts */ /* global GroupsSelect */ @@ -36,6 +35,8 @@ /* global Labels */ /* global Shortcuts */ +const ShortcutsBlob = require('./shortcuts_blob'); + (function() { var Dispatcher; @@ -96,6 +97,7 @@ break; case 'projects:milestones:new': case 'projects:milestones:edit': + case 'projects:milestones:update': new ZenMode(); new gl.DueDateSelectors(); new gl.GLForm($('.milestone-form')); @@ -162,7 +164,7 @@ case 'projects:commit:pipelines': new gl.MiniPipelineGraph({ container: '.js-pipeline-table', - }); + }).bindEvents(); break; case 'projects:commits:show': case 'projects:activity': @@ -225,7 +227,12 @@ case 'projects:blame:show': new LineHighlighter(); shortcut_handler = new ShortcutsNavigation(); - new ShortcutsBlob(true); + const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); + const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); + new ShortcutsBlob({ + skipResetBindings: true, + fileBlobPermalinkUrl, + }); break; case 'groups:labels:new': case 'groups:labels:edit': @@ -259,7 +266,7 @@ new gl.ProtectedBranchCreate(); new gl.ProtectedBranchEditList(); break; - case 'projects:variables:index': + case 'projects:ci_cd:show': new gl.ProjectVariables(); break; case 'ci:lints:create': diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js index c290e1a8355..5cdf11c6a2c 100644 --- a/app/assets/javascripts/droplab/droplab_ajax.js +++ b/app/assets/javascripts/droplab/droplab_ajax.js @@ -78,8 +78,8 @@ require('../window')(function(w){ }, destroy: function() { - if (this.listTemplate) { - var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); + var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); + if (this.listTemplate && dynamicList) { dynamicList.outerHTML = this.listTemplate; } } diff --git a/app/assets/javascripts/due_date_select.js.es6 b/app/assets/javascripts/due_date_select.js.es6 index d81d4cf8425..ab5ce23d261 100644 --- a/app/assets/javascripts/due_date_select.js.es6 +++ b/app/assets/javascripts/due_date_select.js.es6 @@ -1,4 +1,6 @@ /* eslint-disable wrap-iife, func-names, space-before-function-paren, comma-dangle, prefer-template, consistent-return, class-methods-use-this, arrow-body-style, no-unused-vars, no-underscore-dangle, no-new, max-len, no-sequences, no-unused-expressions, no-param-reassign */ +/* global dateFormat */ +/* global Pikaday */ (function(global) { class DueDateSelect { @@ -25,11 +27,14 @@ this.initGlDropdown(); this.initRemoveDueDate(); this.initDatePicker(); - this.initStopPropagation(); } initGlDropdown() { this.$dropdown.glDropdown({ + opened: () => { + const calendar = this.$datePicker.data('pikaday'); + calendar.show(); + }, hidden: () => { this.$selectbox.hide(); this.$value.css('display', ''); @@ -38,25 +43,37 @@ } initDatePicker() { - this.$datePicker.datepicker({ - dateFormat: 'yy-mm-dd', - defaultDate: $("input[name='" + this.fieldName + "']").val(), - altField: "input[name='" + this.fieldName + "']", - onSelect: () => { + const $dueDateInput = $(`input[name='${this.fieldName}']`); + + const calendar = new Pikaday({ + field: $dueDateInput.get(0), + theme: 'gitlab-theme', + format: 'YYYY-MM-DD', + onSelect: (dateText) => { + const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd'); + + $dueDateInput.val(formattedDate); + if (this.$dropdown.hasClass('js-issue-boards-due-date')) { - gl.issueBoards.BoardsStore.detail.issue.dueDate = $(`input[name='${this.fieldName}']`).val(); + gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val(); this.updateIssueBoardIssue(); } else { - return this.saveDueDate(true); + this.saveDueDate(true); } } }); + + this.$datePicker.append(calendar.el); + this.$datePicker.data('pikaday', calendar); } initRemoveDueDate() { this.$block.on('click', '.js-remove-due-date', (e) => { + const calendar = this.$datePicker.data('pikaday'); e.preventDefault(); + calendar.setDate(null); + if (this.$dropdown.hasClass('js-issue-boards-due-date')) { gl.issueBoards.BoardsStore.detail.issue.dueDate = ''; this.updateIssueBoardIssue(); @@ -67,12 +84,6 @@ }); } - initStopPropagation() { - $(document).off('click', '.ui-datepicker-header a').on('click', '.ui-datepicker-header a', (e) => { - return e.stopImmediatePropagation(); - }); - } - saveDueDate(isDropdown) { this.parseSelectedDate(); this.prepSelectedDate(); @@ -86,7 +97,7 @@ // Construct Date object manually to avoid buggy dateString support within Date constructor const dateArray = this.rawSelectedDate.split('-').map(v => parseInt(v, 10)); const dateObj = new Date(dateArray[0], dateArray[1] - 1, dateArray[2]); - this.displayedDate = $.datepicker.formatDate('M d, yy', dateObj); + this.displayedDate = dateFormat(dateObj, 'mmm d, yyyy'); } else { this.displayedDate = 'No due date'; } @@ -153,14 +164,24 @@ } initMilestoneDatePicker() { - $('.datepicker').datepicker({ - dateFormat: 'yy-mm-dd' + $('.datepicker').each(function() { + const $datePicker = $(this); + const calendar = new Pikaday({ + field: $datePicker.get(0), + theme: 'gitlab-theme', + format: 'YYYY-MM-DD', + onSelect(dateText) { + $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); + } + }); + + $datePicker.data('pikaday', calendar); }); $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { e.preventDefault(); - const datepicker = $(e.target).siblings('.datepicker'); - $.datepicker._clearDate(datepicker); + const calendar = $(e.target).siblings('.datepicker').data('pikaday'); + calendar.setDate(null); }); } diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6 index 521873b14b4..39746621c43 100644 --- a/app/assets/javascripts/environments/components/environment_item.js.es6 +++ b/app/assets/javascripts/environments/components/environment_item.js.es6 @@ -2,9 +2,9 @@ /* global timeago */ window.Vue = require('vue'); -window.timeago = require('vendor/timeago'); +window.timeago = require('timeago.js'); require('../../lib/utils/text_utility'); -require('../../vue_common_component/commit'); +require('../../vue_shared/components/commit'); require('./environment_actions'); require('./environment_external_url'); require('./environment_stop'); @@ -147,12 +147,12 @@ require('./environment_terminal_button'); }, /** - * Returns the value of the `stoppable?` key provided in the response. + * Returns the value of the `stop_action?` key provided in the response. * * @returns {Boolean} */ - isStoppable() { - return this.model['stoppable?']; + hasStopAction() { + return this.model['stop_action?']; }, /** @@ -508,7 +508,7 @@ require('./environment_terminal_button'); </external-url-component> </div> - <div v-if="isStoppable && canCreateDeployment" + <div v-if="hasStopAction && canCreateDeployment" class="inline js-stop-component-container"> <stop-component :stop-url="model.stop_path"> diff --git a/app/assets/javascripts/environments/environments_bundle.js.es6 b/app/assets/javascripts/environments/environments_bundle.js.es6 index 58f4c6eadb2..05c59d92fd4 100644 --- a/app/assets/javascripts/environments/environments_bundle.js.es6 +++ b/app/assets/javascripts/environments/environments_bundle.js.es6 @@ -1,8 +1,7 @@ window.Vue = require('vue'); - require('./stores/environments_store'); require('./components/environment'); -require('./vue_resource_interceptor'); +require('../vue_shared/vue_resource_interceptor'); $(() => { window.gl = window.gl || {}; diff --git a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 deleted file mode 100644 index 406bdbc1c7d..00000000000 --- a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 +++ /dev/null @@ -1,12 +0,0 @@ -/* global Vue */ -Vue.http.interceptors.push((request, next) => { - Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; - - next((response) => { - if (typeof response.data === 'string') { - response.data = JSON.parse(response.data); // eslint-disable-line - } - - Vue.activeResources--; // eslint-disable-line - }); -}); diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_user.js.es6 index f93605a5a21..7e9c6f74aa5 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js.es6 +++ b/app/assets/javascripts/filtered_search/dropdown_user.js.es6 @@ -8,7 +8,7 @@ require('./filtered_search_dropdown'); super(droplab, dropdown, input, filter); this.config = { droplabAjaxFilter: { - endpoint: '/autocomplete/users.json', + endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, searchKey: 'search', params: { per_page: 20, diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 index 859d6515531..e8c2df03a46 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 @@ -4,7 +4,7 @@ class FilteredSearchDropdown { constructor(droplab, dropdown, input, filter) { this.droplab = droplab; - this.hookId = input.getAttribute('data-id'); + this.hookId = input && input.getAttribute('data-id'); this.input = input; this.filter = filter; this.dropdown = dropdown; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 index 547989a6ff5..8ce4cf4fc36 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 @@ -2,7 +2,8 @@ (() => { class FilteredSearchDropdownManager { - constructor() { + constructor(baseEndpoint = '') { + this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.tokenizer = gl.FilteredSearchTokenizer; this.filteredSearchInput = document.querySelector('.filtered-search'); @@ -38,13 +39,13 @@ milestone: { reference: null, gl: 'DropdownNonUser', - extraArguments: ['milestones.json', '%'], + extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'], element: document.querySelector('#js-dropdown-milestone'), }, label: { reference: null, gl: 'DropdownNonUser', - extraArguments: ['labels.json', '~'], + extraArguments: [`${this.baseEndpoint}/labels.json`, '~'], element: document.querySelector('#js-dropdown-label'), }, hint: { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 index 4e02ab7c8c1..ffc7d29e4c5 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 @@ -6,7 +6,7 @@ if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(); + this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || ''); this.bindEvents(); this.loadSearchParamsFromURL(); diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index d9101b55c7f..0d618caf350 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -47,9 +47,11 @@ } // Only filter asynchronously only if option remote is set if (this.options.remote) { + $inputContainer.parent().addClass('is-loading'); clearTimeout(timeout); return timeout = setTimeout(function() { return this.options.query(this.input.val(), function(data) { + $inputContainer.parent().removeClass('is-loading'); return this.options.callback(data); }.bind(this)); }.bind(this), 250); @@ -437,7 +439,7 @@ } }; - GitLabDropdown.prototype.opened = function() { + GitLabDropdown.prototype.opened = function(e) { var contentHtml; this.resetRows(); this.addArrowKeyEvent(); @@ -457,6 +459,10 @@ this.positionMenuAbove(); } + if (this.options.opened) { + this.options.opened.call(this, e); + } + return this.dropdown.trigger('shown.gl.dropdown'); }; diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 293b856dc4d..2ec545db665 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -3,6 +3,8 @@ /* global UsersSelect */ /* global ZenMode */ /* global Autosave */ +/* global dateFormat */ +/* global Pikaday */ (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; @@ -13,7 +15,7 @@ IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; function IssuableForm(form) { - var $issuableDueDate; + var $issuableDueDate, calendar; this.form = form; this.toggleWip = bind(this.toggleWip, this); this.renderWipExplanation = bind(this.renderWipExplanation, this); @@ -35,12 +37,14 @@ this.initMoveDropdown(); $issuableDueDate = $('#issuable-due-date'); if ($issuableDueDate.length) { - $('.datepicker').datepicker({ - dateFormat: 'yy-mm-dd', - onSelect: function(dateText, inst) { - return $issuableDueDate.val(dateText); + calendar = new Pikaday({ + field: $issuableDueDate.get(0), + theme: 'gitlab-theme', + format: 'YYYY-MM-DD', + onSelect: function(dateText) { + $issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); } - }).datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $issuableDueDate.val())); + }); } } diff --git a/app/assets/javascripts/label_manager.js.es6 b/app/assets/javascripts/label_manager.js.es6 index 2a50b72c8aa..38b2eb9ff14 100644 --- a/app/assets/javascripts/label_manager.js.es6 +++ b/app/assets/javascripts/label_manager.js.es6 @@ -1,5 +1,6 @@ /* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */ /* global Flash */ +/* global Sortable */ ((global) => { class LabelManager { @@ -9,11 +10,12 @@ this.otherLabels = otherLabels || $('.js-other-labels'); this.errorMessage = 'Unable to update label prioritization at this time'; this.emptyState = document.querySelector('#js-priority-labels-empty-state'); - this.prioritizedLabels.sortable({ - items: 'li', - placeholder: 'list-placeholder', - axis: 'y', - update: this.onPrioritySortUpdate.bind(this) + this.sortable = Sortable.create(this.prioritizedLabels.get(0), { + filter: '.empty-message', + forceFallback: true, + fallbackClass: 'is-dragging', + dataIdAttr: 'data-id', + onUpdate: this.onPrioritySortUpdate.bind(this), }); this.bindEvents(); } @@ -51,13 +53,13 @@ $target = this.otherLabels; $from = this.prioritizedLabels; } - if ($from.find('li').length === 1) { + $label.detach().appendTo($target); + if ($from.find('li').length) { $from.find('.empty-message').removeClass('hidden'); } - if (!$target.find('li').length) { + if ($target.find('> li:not(.empty-message)').length) { $target.find('.empty-message').addClass('hidden'); } - $label.detach().appendTo($target); // Return if we are not persisting state if (!persistState) { return; @@ -101,8 +103,12 @@ getSortedLabelsIds() { const sortedIds = []; - this.prioritizedLabels.find('li').each(function() { - sortedIds.push($(this).data('id')); + this.prioritizedLabels.find('> li').each(function() { + const id = $(this).data('id'); + + if (id) { + sortedIds.push(id); + } }); return sortedIds; } diff --git a/app/assets/javascripts/lib/utils/common_utils.js.es6 b/app/assets/javascripts/lib/utils/common_utils.js.es6 index e3bff2559fd..bcb3a706b51 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js.es6 +++ b/app/assets/javascripts/lib/utils/common_utils.js.es6 @@ -69,27 +69,21 @@ var hash = w.gl.utils.getLocationHash(); if (!hash) return; - var navbar = document.querySelector('.navbar-gitlab'); - var subnav = document.querySelector('.layout-nav'); - var fixedTabs = document.querySelector('.js-tabs-affix'); - - var adjustment = 0; - if (navbar) adjustment -= navbar.offsetHeight; - if (subnav) adjustment -= subnav.offsetHeight; + // This is required to handle non-unicode characters in hash + hash = decodeURIComponent(hash); // scroll to user-generated markdown anchor if we cannot find a match if (document.getElementById(hash) === null) { var target = document.getElementById('user-content-' + hash); if (target && target.scrollIntoView) { target.scrollIntoView(true); - window.scrollBy(0, adjustment); } } else { // only adjust for fixedTabs when not targeting user-generated content + var fixedTabs = document.querySelector('.js-tabs-affix'); if (fixedTabs) { - adjustment -= fixedTabs.offsetHeight; + window.scrollBy(0, -fixedTabs.offsetHeight); } - window.scrollBy(0, adjustment); } }; @@ -134,14 +128,20 @@ return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; }; + gl.utils.isMetaClick = function(e) { + // Identify following special clicks + // 1) Cmd + Click on Mac (e.metaKey) + // 2) Ctrl + Click on PC (e.ctrlKey) + // 3) Middle-click or Mouse Wheel Click (e.which is 2) + return e.metaKey || e.ctrlKey || e.which === 2; + }; + gl.utils.scrollToElement = function($el) { var top = $el.offset().top; - gl.navBarHeight = gl.navBarHeight || $('.navbar-gitlab').height(); - gl.navLinksHeight = gl.navLinksHeight || $('.nav-links').height(); gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height(); return $('body, html').animate({ - scrollTop: top - (gl.navBarHeight + gl.navLinksHeight + gl.mrTabsHeight) + scrollTop: top - (gl.mrTabsHeight) }, 200); }; @@ -230,5 +230,16 @@ return upperCaseHeaders; }; + + /** + * Transforms a DOMStringMap into a plain object. + * + * @param {DOMStringMap} DOMStringMapObject + * @returns {Object} + */ + w.gl.utils.DOMStringMapToObject = DOMStringMapObject => Object.keys(DOMStringMapObject).reduce((acc, element) => { + acc[element] = DOMStringMapObject[element]; + return acc; + }, {}); })(window); }).call(this); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js deleted file mode 100644 index 5128ffd8c6f..00000000000 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */ -/* global timeago */ -/* global dateFormat */ - -window.timeago = require('vendor/timeago'); -window.dateFormat = require('vendor/date.format'); - -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; - } - if ((base = w.gl).utils == null) { - base.utils = {}; - } - w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - - w.gl.utils.formatDate = function(datetime) { - return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); - }; - - w.gl.utils.getDayName = function(date) { - return this.days[date.getDay()]; - }; - - w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) { - if (setTimeago == null) { - setTimeago = true; - } - - $timeagoEls.filter(':not([data-timeago-rendered])').each(function() { - var $el = $(this); - $el.attr('title', gl.utils.formatDate($el.attr('datetime'))); - - if (setTimeago) { - // Recreate with custom template - $el.tooltip({ - template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>' - }); - } - - $el.attr('data-timeago-rendered', true); - gl.utils.renderTimeago($el); - }); - }; - - w.gl.utils.getTimeago = function() { - var locale = function(number, index) { - return [ - ['less than a minute ago', 'a while'], - ['less than a minute ago', 'in %s seconds'], - ['about a minute ago', 'in 1 minute'], - ['%s minutes ago', 'in %s minutes'], - ['about an hour ago', 'in 1 hour'], - ['about %s hours ago', 'in %s hours'], - ['a day ago', 'in 1 day'], - ['%s days ago', 'in %s days'], - ['a week ago', 'in 1 week'], - ['%s weeks ago', 'in %s weeks'], - ['a month ago', 'in 1 month'], - ['%s months ago', 'in %s months'], - ['a year ago', 'in 1 year'], - ['%s years ago', 'in %s years'] - ][index]; - }; - - timeago.register('gl_en', locale); - return timeago(); - }; - - w.gl.utils.timeFor = function(time, suffix, expiredLabel) { - var timefor; - if (!time) { - return ''; - } - suffix || (suffix = 'remaining'); - expiredLabel || (expiredLabel = 'Past due'); - timefor = gl.utils.getTimeago().format(time).replace('in', ''); - if (timefor.indexOf('ago') > -1) { - timefor = expiredLabel; - } else { - timefor = timefor.trim() + ' ' + suffix; - } - return timefor; - }; - - w.gl.utils.renderTimeago = function($element) { - var timeagoInstance = gl.utils.getTimeago(); - timeagoInstance.render($element, 'gl_en'); - }; - - w.gl.utils.getDayDifference = function(a, b) { - var millisecondsPerDay = 1000 * 60 * 60 * 24; - var date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); - var date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); - - return Math.floor((date2 - date1) / millisecondsPerDay); - }; - })(window); -}).call(this); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js.es6 b/app/assets/javascripts/lib/utils/datetime_utility.js.es6 new file mode 100644 index 00000000000..f41fa15b147 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime_utility.js.es6 @@ -0,0 +1,126 @@ +/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */ +/* global timeago */ +/* global dateFormat */ + +window.timeago = require('timeago.js'); +window.dateFormat = require('vendor/date.format'); + +(function() { + (function(w) { + var base; + var timeagoInstance; + + if (w.gl == null) { + w.gl = {}; + } + if ((base = w.gl).utils == null) { + base.utils = {}; + } + w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + w.gl.utils.formatDate = function(datetime) { + return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); + }; + + w.gl.utils.getDayName = function(date) { + return this.days[date.getDay()]; + }; + + w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago = true) { + $timeagoEls.each((i, el) => { + el.setAttribute('title', gl.utils.formatDate(el.getAttribute('datetime'))); + + if (setTimeago) { + // Recreate with custom template + $(el).tooltip({ + template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>' + }); + } + + el.classList.add('js-timeago-render'); + }); + + gl.utils.renderTimeago($timeagoEls); + }; + + w.gl.utils.getTimeago = function() { + var locale; + + if (!timeagoInstance) { + locale = function(number, index) { + return [ + ['less than a minute ago', 'a while'], + ['less than a minute ago', 'in %s seconds'], + ['about a minute ago', 'in 1 minute'], + ['%s minutes ago', 'in %s minutes'], + ['about an hour ago', 'in 1 hour'], + ['about %s hours ago', 'in %s hours'], + ['a day ago', 'in 1 day'], + ['%s days ago', 'in %s days'], + ['a week ago', 'in 1 week'], + ['%s weeks ago', 'in %s weeks'], + ['a month ago', 'in 1 month'], + ['%s months ago', 'in %s months'], + ['a year ago', 'in 1 year'], + ['%s years ago', 'in %s years'] + ][index]; + }; + + timeago.register('gl_en', locale); + timeagoInstance = timeago(); + } + + return timeagoInstance; + }; + + w.gl.utils.timeFor = function(time, suffix, expiredLabel) { + var timefor; + if (!time) { + return ''; + } + suffix || (suffix = 'remaining'); + expiredLabel || (expiredLabel = 'Past due'); + timefor = gl.utils.getTimeago().format(time).replace('in', ''); + if (timefor.indexOf('ago') > -1) { + timefor = expiredLabel; + } else { + timefor = timefor.trim() + ' ' + suffix; + } + return timefor; + }; + + w.gl.utils.cachedTimeagoElements = []; + w.gl.utils.renderTimeago = function($els) { + if (!$els && !w.gl.utils.cachedTimeagoElements.length) { + w.gl.utils.cachedTimeagoElements = [].slice.call(document.querySelectorAll('.js-timeago-render')); + } else if ($els) { + w.gl.utils.cachedTimeagoElements = w.gl.utils.cachedTimeagoElements.concat($els.toArray()); + } + + w.gl.utils.cachedTimeagoElements.forEach(gl.utils.updateTimeagoText); + }; + + w.gl.utils.updateTimeagoText = function(el) { + const timeago = gl.utils.getTimeago(); + const formattedDate = timeago.format(el.getAttribute('datetime'), 'gl_en'); + + if (el.textContent !== formattedDate) { + el.textContent = formattedDate; + } + }; + + w.gl.utils.initTimeagoTimeout = function() { + gl.utils.renderTimeago(); + + gl.utils.timeagoTimeout = setTimeout(gl.utils.initTimeagoTimeout, 1000); + }; + + w.gl.utils.getDayDifference = function(a, b) { + var millisecondsPerDay = 1000 * 60 * 60 * 24; + var date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + var date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + + return Math.floor((date2 - date1) / millisecondsPerDay); + }; + })(window); +}).call(this); diff --git a/app/assets/javascripts/member_expiration_date.js.es6 b/app/assets/javascripts/member_expiration_date.js.es6 index bf6c0ec2798..f57d4a20498 100644 --- a/app/assets/javascripts/member_expiration_date.js.es6 +++ b/app/assets/javascripts/member_expiration_date.js.es6 @@ -1,3 +1,5 @@ +/* global Pikaday */ +/* global dateFormat */ (() => { // Add datepickers to all `js-access-expiration-date` elements. If those elements are // children of an element with the `clearable-input` class, and have a sibling @@ -11,21 +13,34 @@ } const inputs = $(selector); - inputs.datepicker({ - dateFormat: 'yy-mm-dd', - minDate: 1, - onSelect: function onSelect() { - $(this).trigger('change'); - toggleClearInput.call(this); - }, + inputs.each((i, el) => { + const $input = $(el); + + const calendar = new Pikaday({ + field: $input.get(0), + theme: 'gitlab-theme', + format: 'YYYY-MM-DD', + minDate: new Date(), + onSelect(dateText) { + $input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); + + $input.trigger('change'); + + toggleClearInput.call($input); + }, + }); + + $input.data('pikaday', calendar); }); inputs.next('.js-clear-input').on('click', function clicked(event) { event.preventDefault(); const input = $(this).closest('.clearable-input').find(selector); - input.datepicker('setDate', null) - .trigger('change'); + const calendar = input.data('pikaday'); + + calendar.setDate(null); + input.trigger('change'); toggleClearInput.call(input); }); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 8762ec35b80..e65378cd610 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -115,8 +115,8 @@ require('./merge_request_tabs'); e.preventDefault(); textarea.val(textarea.data('messageWithDescription')); - $('p.js-with-description-hint').hide(); - $('p.js-without-description-hint').show(); + $('.js-with-description-hint').hide(); + $('.js-without-description-hint').show(); }); $(document).on('click', 'a.js-without-description-link', function(e) { @@ -124,8 +124,8 @@ require('./merge_request_tabs'); e.preventDefault(); textarea.val(textarea.data('messageWithoutDescription')); - $('p.js-with-description-hint').show(); - $('p.js-without-description-hint').hide(); + $('.js-with-description-hint').show(); + $('.js-without-description-hint').hide(); }); }; diff --git a/app/assets/javascripts/merge_request_tabs.js.es6 b/app/assets/javascripts/merge_request_tabs.js.es6 index 7e74bebb81e..cc049e00477 100644 --- a/app/assets/javascripts/merge_request_tabs.js.es6 +++ b/app/assets/javascripts/merge_request_tabs.js.es6 @@ -4,7 +4,7 @@ /* global Flash */ require('./breakpoints'); -window.Cookies = require('vendor/js.cookie'); +window.Cookies = require('js-cookie'); require('./flash'); /* eslint-disable max-len */ @@ -61,7 +61,6 @@ require('./flash'); constructor({ action, setUrl, stubLocation } = {}) { this.diffsLoaded = false; - this.pipelinesLoaded = false; this.commitsLoaded = false; this.fixedLayoutPref = null; @@ -83,12 +82,18 @@ require('./flash'); $(document) .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) .on('click', '.js-show-tab', this.showTab); + + $('.merge-request-tabs a[data-toggle="tab"]') + .on('click', this.clickTab); } unbindEvents() { $(document) .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) .off('click', '.js-show-tab', this.showTab); + + $('.merge-request-tabs a[data-toggle="tab"]') + .off('click', this.clickTab); } showTab(e) { @@ -96,6 +101,14 @@ require('./flash'); this.activateTab($(e.target).data('action')); } + clickTab(e) { + if (e.target && gl.utils.isMetaClick(e)) { + const targetLink = e.target.getAttribute('href'); + e.stopImmediatePropagation(); + window.open(targetLink, '_blank'); + } + } + tabShown(e) { const $target = $(e.target); const action = $target.data('action'); @@ -112,14 +125,9 @@ require('./flash'); if (this.diffViewType() === 'parallel') { this.expandViewContainer(); } - const navBarHeight = $('.navbar-gitlab').outerHeight(); $.scrollTo('.merge-request-details .merge-request-tabs', { - offset: -navBarHeight, + offset: 0, }); - } else if (action === 'pipelines') { - this.loadPipelines($target.attr('href')); - this.expandView(); - this.resetViewContainer(); } else { this.expandView(); this.resetViewContainer(); @@ -131,11 +139,7 @@ require('./flash'); scrollToElement(container) { if (location.hash) { - const offset = 0 - ( - $('.navbar-gitlab').outerHeight() + - $('.layout-nav').outerHeight() + - $('.js-tabs-affix').outerHeight() - ); + const offset = -$('.js-tabs-affix').outerHeight(); const $el = $(`${container} ${location.hash}:not(.match)`); if ($el.length) { $.scrollTo($el[0], { offset }); @@ -244,25 +248,6 @@ require('./flash'); }); } - loadPipelines(source) { - if (this.pipelinesLoaded) { - return; - } - this.ajaxGet({ - url: `${source}.json`, - success: (data) => { - $('#pipelines').html(data.html); - gl.utils.localTimeAgo($('.js-timeago', '#pipelines')); - this.pipelinesLoaded = true; - this.scrollToElement('#pipelines'); - - new gl.MiniPipelineGraph({ - container: '.js-pipeline-table', - }); - }, - }); - } - // Show or hide the loading spinner // // status - Boolean, true to show, false to hide @@ -340,14 +325,12 @@ require('./flash'); if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return; const $diffTabs = $('#diff-notes-app'); - const $fixedNav = $('.navbar-fixed-top'); - const $layoutNav = $('.layout-nav'); $tabs.off('affix.bs.affix affix-top.bs.affix') .affix({ offset: { top: () => ( - $diffTabs.offset().top - $tabs.height() - $fixedNav.height() - $layoutNav.height() + $diffTabs.offset().top - $tabs.height() ), }, }) diff --git a/app/assets/javascripts/merge_request_widget.js.es6 b/app/assets/javascripts/merge_request_widget.js.es6 index 05b9a63765f..69aed77c83d 100644 --- a/app/assets/javascripts/merge_request_widget.js.es6 +++ b/app/assets/javascripts/merge_request_widget.js.es6 @@ -51,6 +51,8 @@ require('./smart_interval'); this.getCIStatus(false); this.retrieveSuccessIcon(); + this.initMiniPipelineGraph(); + this.ciStatusInterval = new global.SmartInterval({ callback: this.getCIStatus.bind(this, true), startingInterval: 10000, @@ -66,6 +68,7 @@ require('./smart_interval'); incrementByFactorOf: 15000, immediateExecution: true, }); + notifyPermissions(); } @@ -151,7 +154,7 @@ require('./smart_interval'); return $.getJSON(this.opts.ci_status_url, (function(_this) { return function(data) { var message, status, title; - if (data.status === '') { + if (!data.status) { return; } if (data.environments && data.environments.length) _this.renderEnvironments(data.environments); @@ -236,17 +239,20 @@ require('./smart_interval'); case "failed": case "canceled": case "not_found": - return this.setMergeButtonClass('btn-danger'); + this.setMergeButtonClass('btn-danger'); + break; case "running": - return this.setMergeButtonClass('btn-info'); + this.setMergeButtonClass('btn-info'); + break; case "success": case "success_with_warnings": - return this.setMergeButtonClass('btn-create'); + this.setMergeButtonClass('btn-create'); } } else { $('.ci_widget.ci-error').show(); - return this.setMergeButtonClass('btn-danger'); + this.setMergeButtonClass('btn-danger'); } + this.initMiniPipelineGraph(); }; MergeRequestWidget.prototype.showCICoverage = function(coverage) { @@ -269,6 +275,12 @@ require('./smart_interval'); $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/')); }; + MergeRequestWidget.prototype.initMiniPipelineGraph = function() { + new gl.MiniPipelineGraph({ + container: '.js-pipeline-inline-mr-widget-graph:visible', + }).bindEvents(); + }; + return MergeRequestWidget; })(); })(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 7ce1259e015..051cb9fe5c5 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-use-before-define, camelcase, quotes, object-shorthand, no-shadow, no-unused-vars, comma-dangle, no-var, prefer-template, no-underscore-dangle, consistent-return, one-var, one-var-declaration-per-line, default-case, prefer-arrow-callback, max-len */ /* global Flash */ +/* global Sortable */ (function() { this.Milestone = (function() { @@ -8,11 +9,9 @@ type: "PUT", url: issue_url, data: data, - success: (function(_this) { - return function(_data) { - return _this.successCallback(_data, li); - }; - })(this), + success: function(_data) { + return Milestone.successCallback(_data, li); + }, error: function(data) { return new Flash("Issue update failed", 'alert'); }, @@ -27,11 +26,9 @@ type: "PUT", url: sort_issues_url, data: data, - success: (function(_this) { - return function(_data) { - return _this.successCallback(_data); - }; - })(this), + success: function(_data) { + return Milestone.successCallback(_data); + }, error: function() { return new Flash("Issues update failed", 'alert'); }, @@ -46,11 +43,9 @@ type: "PUT", url: sort_mr_url, data: data, - success: (function(_this) { - return function(_data) { - return _this.successCallback(_data); - }; - })(this), + success: function(_data) { + return Milestone.successCallback(_data); + }, error: function(data) { return new Flash("Issue update failed", 'alert'); }, @@ -63,11 +58,9 @@ type: "PUT", url: merge_request_url, data: data, - success: (function(_this) { - return function(_data) { - return _this.successCallback(_data, li); - }; - })(this), + success: function(_data) { + return Milestone.successCallback(_data, li); + }, error: function(data) { return new Flash("Issue update failed", 'alert'); }, @@ -81,65 +74,30 @@ img_tag = $('<img/>'); img_tag.attr('src', data.assignee.avatar_url); img_tag.addClass('avatar s16'); - $(element).find('.assignee-icon').html(img_tag); + $(element).find('.assignee-icon img').replaceWith(img_tag); } else { - $(element).find('.assignee-icon').html(''); + $(element).find('.assignee-icon').empty(); } return $(element).effect('highlight'); }; function Milestone() { var oldMouseStart; - oldMouseStart = $.ui.sortable.prototype._mouseStart; - $.ui.sortable.prototype._mouseStart = function(event, overrideHandle, noActivation) { - this._trigger("beforeStart", event, this._uiHash()); - return oldMouseStart.apply(this, [event, overrideHandle, noActivation]); - }; this.bindIssuesSorting(); this.bindMergeRequestSorting(); this.bindTabsSwitching(); } Milestone.prototype.bindIssuesSorting = function() { - return $("#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed").sortable({ - connectWith: ".issues-sortable-list", - dropOnEmpty: true, - items: "li:not(.ui-sort-disabled)", - beforeStart: function(event, ui) { - return $(".issues-sortable-list").css("min-height", ui.item.outerHeight()); - }, - stop: function(event, ui) { - return $(".issues-sortable-list").css("min-height", "0px"); - }, - update: function(event, ui) { - var data; - // Prevents sorting from container which element has been removed. - if ($(this).find(ui.item).length > 0) { - data = $(this).sortable("serialize"); - return Milestone.sortIssues(data); - } - }, - receive: function(event, ui) { - var data, issue_id, issue_url, new_state; - new_state = $(this).data('state'); - issue_id = ui.item.data('iid'); - issue_url = ui.item.data('url'); - data = (function() { - switch (new_state) { - case 'ongoing': - return "issue[assignee_id]=" + gon.current_user_id; - case 'unassigned': - return "issue[assignee_id]="; - case 'closed': - return "issue[state_event]=close"; - } - })(); - if ($(ui.sender).data('state') === "closed") { - data += "&issue[state_event]=reopen"; - } - return Milestone.updateIssue(ui.item, issue_url, data); - } - }).disableSelection(); + $('#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed').each(function (i, el) { + this.createSortable(el, { + group: 'issue-list', + listEls: $('.issues-sortable-list'), + fieldName: 'issue', + sortCallback: Milestone.sortIssues, + updateCallback: Milestone.updateIssue, + }); + }.bind(this)); }; Milestone.prototype.bindTabsSwitching = function() { @@ -154,42 +112,62 @@ }; Milestone.prototype.bindMergeRequestSorting = function() { - return $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").sortable({ - connectWith: ".merge_requests-sortable-list", - dropOnEmpty: true, - items: "li:not(.ui-sort-disabled)", - beforeStart: function(event, ui) { - return $(".merge_requests-sortable-list").css("min-height", ui.item.outerHeight()); + $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").each(function (i, el) { + this.createSortable(el, { + group: 'merge-request-list', + listEls: $(".merge_requests-sortable-list:not(#merge_requests-list-merged)"), + fieldName: 'merge_request', + sortCallback: Milestone.sortMergeRequests, + updateCallback: Milestone.updateMergeRequest, + }); + }.bind(this)); + }; + + Milestone.prototype.createSortable = function(el, opts) { + return Sortable.create(el, { + group: opts.group, + filter: '.is-disabled', + forceFallback: true, + onStart: function(e) { + opts.listEls.css('min-height', e.item.offsetHeight); }, - stop: function(event, ui) { - return $(".merge_requests-sortable-list").css("min-height", "0px"); + onEnd: function () { + opts.listEls.css("min-height", "0px"); }, - update: function(event, ui) { - var data; - data = $(this).sortable("serialize"); - return Milestone.sortMergeRequests(data); + onUpdate: function(e) { + var ids = this.toArray(), + data; + + if (ids.length) { + data = ids.map(function(id) { + return 'sortable_' + opts.fieldName + '[]=' + id; + }).join('&'); + + opts.sortCallback(data); + } }, - receive: function(event, ui) { - var data, merge_request_id, merge_request_url, new_state; - new_state = $(this).data('state'); - merge_request_id = ui.item.data('iid'); - merge_request_url = ui.item.data('url'); + onAdd: function (e) { + var data, issuableId, issuableUrl, newState; + newState = e.to.dataset.state; + issuableUrl = e.item.dataset.url; data = (function() { - switch (new_state) { + switch (newState) { case 'ongoing': - return "merge_request[assignee_id]=" + gon.current_user_id; + return opts.fieldName + '[assignee_id]=' + gon.current_user_id; case 'unassigned': - return "merge_request[assignee_id]="; + return opts.fieldName + '[assignee_id]='; case 'closed': - return "merge_request[state_event]=close"; + return opts.fieldName + '[state_event]=close'; } })(); - if ($(ui.sender).data('state') === "closed") { - data += "&merge_request[state_event]=reopen"; + if (e.from.dataset.state === 'closed') { + data += '&' + opts.fieldName + '[state_event]=reopen'; } - return Milestone.updateMergeRequest(ui.item, merge_request_url, data); + + opts.updateCallback(e.item, issuableUrl, data); + this.options.onUpdate.call(this, e); } - }).disableSelection(); + }); }; return Milestone; diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js.es6 b/app/assets/javascripts/mini_pipeline_graph_dropdown.js.es6 index 80549532ea9..919fcd0a07b 100644 --- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js.es6 +++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js.es6 @@ -21,8 +21,6 @@ this.container = opts.container || ''; this.dropdownListSelector = '.js-builds-dropdown-container'; this.getBuildsList = this.getBuildsList.bind(this); - - this.bindEvents(); } /** @@ -30,7 +28,7 @@ * All dropdown events are fired at the .dropdown-menu's parent element. */ bindEvents() { - $(this.container).on('shown.bs.dropdown', this.getBuildsList); + $(document).on('shown.bs.dropdown', this.container, this.getBuildsList); } /** diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index d108da29af7..3579843baed 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -455,7 +455,7 @@ require('vendor/task_list'); var mergeRequestId = $form.data('noteable-iid'); if (ResolveService != null) { - ResolveService.toggleResolveForDiscussion(projectPath, mergeRequestId, discussionId); + ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId); } } diff --git a/app/assets/javascripts/profile/profile.js.es6 b/app/assets/javascripts/profile/profile.js.es6 index 5aec9c813fe..81374296522 100644 --- a/app/assets/javascripts/profile/profile.js.es6 +++ b/app/assets/javascripts/profile/profile.js.es6 @@ -25,6 +25,7 @@ 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/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js deleted file mode 100644 index a3e549a2735..00000000000 --- a/app/assets/javascripts/shortcuts_blob.js +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, max-len, one-var, no-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, consistent-return */ -/* global Shortcuts */ -/* global Mousetrap */ - -require('./shortcuts'); - -(function() { - var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, - hasProp = {}.hasOwnProperty; - - this.ShortcutsBlob = (function(superClass) { - extend(ShortcutsBlob, superClass); - - function ShortcutsBlob(skipResetBindings) { - ShortcutsBlob.__super__.constructor.call(this, skipResetBindings); - Mousetrap.bind('y', ShortcutsBlob.copyToClipboard); - } - - ShortcutsBlob.copyToClipboard = function() { - var clipboardButton; - clipboardButton = $('.btn-clipboard'); - if (clipboardButton) { - return clipboardButton.click(); - } - }; - - return ShortcutsBlob; - })(Shortcuts); -}).call(this); diff --git a/app/assets/javascripts/shortcuts_blob.js.es6 b/app/assets/javascripts/shortcuts_blob.js.es6 new file mode 100644 index 00000000000..bfe90aef71e --- /dev/null +++ b/app/assets/javascripts/shortcuts_blob.js.es6 @@ -0,0 +1,29 @@ +/* global Mousetrap */ +/* global Shortcuts */ + +require('./shortcuts'); + +const defaults = { + skipResetBindings: false, + fileBlobPermalinkUrl: null, +}; + +class ShortcutsBlob extends Shortcuts { + constructor(opts) { + const options = Object.assign({}, defaults, opts); + super(options.skipResetBindings); + this.options = options; + + Mousetrap.bind('y', this.moveToFilePermalink.bind(this)); + } + + moveToFilePermalink() { + if (this.options.fileBlobPermalinkUrl) { + const hash = gl.utils.getLocationHash(); + const hashUrlString = hash ? `#${hash}` : ''; + gl.utils.visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`); + } + } +} + +module.exports = ShortcutsBlob; diff --git a/app/assets/javascripts/sidebar.js.es6 b/app/assets/javascripts/sidebar.js.es6 index ee172f2fa6f..33e4b7db681 100644 --- a/app/assets/javascripts/sidebar.js.es6 +++ b/app/assets/javascripts/sidebar.js.es6 @@ -1,14 +1,12 @@ /* eslint-disable arrow-parens, class-methods-use-this, no-param-reassign */ /* global Cookies */ -((global) => { - let singleton; - +(() => { const pinnedStateCookie = 'pin_nav'; const sidebarBreakpoint = 1024; const pageSelector = '.page-with-sidebar'; - const navbarSelector = '.navbar-fixed-top'; + const navbarSelector = '.navbar-gitlab'; const sidebarWrapperSelector = '.sidebar-wrapper'; const sidebarContentSelector = '.nav-sidebar'; @@ -23,11 +21,12 @@ class Sidebar { constructor() { - if (!singleton) { - singleton = this; - singleton.init(); + if (!Sidebar.singleton) { + Sidebar.singleton = this; + Sidebar.singleton.init(); } - return singleton; + + return Sidebar.singleton; } init() { @@ -36,13 +35,16 @@ window.innerWidth >= sidebarBreakpoint && $(pageSelector).hasClass(expandedPageClass) ); + $(window).on('resize', () => this.setSidebarHeight()); $(document) .on('click', sidebarToggleSelector, () => this.toggleSidebar()) .on('click', pinnedToggleSelector, () => this.togglePinnedState()) - .on('click', 'html, body', (e) => this.handleClickEvent(e)) + .on('click', 'html, body, a, button', (e) => this.handleClickEvent(e)) .on('DOMContentLoaded', () => this.renderState()) + .on('scroll', () => this.setSidebarHeight()) .on('todo:toggle', (e, count) => this.updateTodoCount(count)); this.renderState(); + this.setSidebarHeight(); } handleClickEvent(e) { @@ -65,6 +67,16 @@ this.renderState(); } + setSidebarHeight() { + const $navHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight(); + const diff = $navHeight - $('body').scrollTop(); + if (diff > 0) { + $('.js-right-sidebar').outerHeight($(window).height() - diff); + } else { + $('.js-right-sidebar').outerHeight('100%'); + } + } + togglePinnedState() { this.isPinned = !this.isPinned; if (!this.isPinned) { @@ -88,10 +100,12 @@ $pinnedToggle.attr('title', tooltipText).tooltip('fixTitle').tooltip(tooltipState); if (this.isExpanded) { - setTimeout(() => $(sidebarContentSelector).niceScroll().updateScrollBar(), 200); + const sidebarContent = $(sidebarContentSelector); + setTimeout(() => { sidebarContent.niceScroll().updateScrollBar(); }, 200); } } } - global.Sidebar = Sidebar; -})(window.gl || (window.gl = {})); + window.gl = window.gl || {}; + gl.Sidebar = Sidebar; +})(); diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 5b20c63384c..3ee0c73a8d2 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -33,13 +33,13 @@ this.$toggleIcon.addClass('fa-caret-down'); } - $('.file-title, .click-to-expand', this.file).on('click', (function (e) { + $('.js-file-title, .click-to-expand', this.file).on('click', (function (e) { this.toggleDiff($(e.target)); }).bind(this)); } SingleFileDiff.prototype.toggleDiff = function($target, cb) { - if (!$target.hasClass('file-title') && !$target.hasClass('click-to-expand') && !$target.hasClass('diff-toggle-caret')) return; + if (!$target.hasClass('js-file-title') && !$target.hasClass('click-to-expand') && !$target.hasClass('diff-toggle-caret')) return; this.isOpen = !this.isOpen; if (!this.isOpen && !this.hasError) { this.content.hide(); diff --git a/app/assets/javascripts/boards/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index f05780167bf..7dba5840c8a 100644 --- a/app/assets/javascripts/boards/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -50,14 +50,15 @@ return ( children[target.index] || children[target.index === 'first' ? 0 : -1] || - children[target.index === 'last' ? children.length - 1 : -1] + children[target.index === 'last' ? children.length - 1 : -1] || + el ); } function getRect(el) { var rect = el.getBoundingClientRect(); var width = rect.right - rect.left; - var height = rect.bottom - rect.top; + var height = rect.bottom - rect.top + 10; return { x: rect.left, diff --git a/app/assets/javascripts/todos.js.es6 b/app/assets/javascripts/todos.js.es6 index 96c7d927509..b07e62a8c30 100644 --- a/app/assets/javascripts/todos.js.es6 +++ b/app/assets/javascripts/todos.js.es6 @@ -146,14 +146,26 @@ } goToTodoUrl(e) { - const todoLink = $(this).data('url'); + const todoLink = this.dataset.url; + let targetLink = e.target.getAttribute('href'); + + if (e.target.tagName === 'IMG') { // See if clicked target was Avatar + targetLink = e.target.parentElement.getAttribute('href'); // Parent of Avatar is link + } + if (!todoLink) { return; } - // Allow Meta-Click or Mouse3-click to open in a new tab - if (e.metaKey || e.which === 2) { + + if (gl.utils.isMetaClick(e)) { e.preventDefault(); - return window.open(todoLink, '_blank'); + // Meta-Click on username leads to different URL than todoLink. + // Turbolinks can resolve that URL, but window.open requires URL manually. + if (targetLink !== todoLink) { + return window.open(targetLink, '_blank'); + } else { + return window.open(todoLink, '_blank'); + } } else { return gl.utils.visitUrl(todoLink); } diff --git a/app/assets/javascripts/vue_pipelines_index/index.js.es6 b/app/assets/javascripts/vue_pipelines_index/index.js.es6 index e1bebe0fe5b..e7432afb56e 100644 --- a/app/assets/javascripts/vue_pipelines_index/index.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/index.js.es6 @@ -1,41 +1,36 @@ +/* eslint-disable no-param-reassign */ /* global Vue, VueResource, gl */ window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -require('../vue_common_component/commit'); -require('../vue_pagination/index'); -require('../boards/vue_resource_interceptor'); -require('./status'); -require('./store'); -require('./pipeline_url'); -require('./stage'); -require('./stages'); -require('./pipeline_actions'); -require('./time_ago'); +require('../lib/utils/common_utils'); +require('../vue_shared/vue_resource_interceptor'); require('./pipelines'); -(() => { - const project = document.querySelector('.pipelines'); - const entry = document.querySelector('.vue-pipelines-index'); - const svgs = document.querySelector('.pipeline-svgs'); +$(() => new Vue({ + el: document.querySelector('.vue-pipelines-index'), - if (!entry) return null; - return new Vue({ - el: entry, - data: { + data() { + const project = document.querySelector('.pipelines'); + const svgs = document.querySelector('.pipeline-svgs').dataset; + + // Transform svgs DOMStringMap to a plain Object. + const svgsObject = gl.utils.DOMStringMapToObject(svgs); + + return { scope: project.dataset.url, store: new gl.PipelineStore(), - svgs: svgs.dataset, - }, - components: { - 'vue-pipelines': gl.VuePipelines, - }, - template: ` - <vue-pipelines - :scope='scope' - :store='store' - :svgs='svgs' - > - </vue-pipelines> - `, - }); -})(); + svgs: svgsObject, + }; + }, + components: { + 'vue-pipelines': gl.VuePipelines, + }, + template: ` + <vue-pipelines + :scope='scope' + :store='store' + :svgs='svgs' + > + </vue-pipelines> + `, +})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 index 01f8b6519a4..8106934e864 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 @@ -50,9 +50,9 @@ <button v-if='artifacts' class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download" - data-toggle="dropdown" title="Artifacts" data-placement="top" + data-toggle="dropdown" aria-label="Artifacts" > <i class="fa fa-download" aria-hidden="true"></i> @@ -81,8 +81,7 @@ data-placement="top" data-toggle="dropdown" :href='pipeline.retry_path' - aria-label="Retry" - > + aria-label="Retry"> <i class="fa fa-repeat" aria-hidden="true"></i> </a> <a @@ -94,8 +93,7 @@ data-placement="top" data-toggle="dropdown" :href='pipeline.cancel_path' - aria-label="Cancel" - > + aria-label="Cancel"> <i class="fa fa-remove" aria-hidden="true"></i> </a> </div> diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 index 194bbae07d9..e47dc6935d6 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 @@ -1,19 +1,19 @@ /* global Vue, gl */ /* eslint-disable no-param-reassign */ +window.Vue = require('vue'); +require('../vue_shared/components/table_pagination'); +require('./store'); +require('../vue_shared/components/pipelines_table'); + ((gl) => { gl.VuePipelines = Vue.extend({ + components: { - runningPipeline: gl.VueRunningPipeline, - pipelineActions: gl.VuePipelineActions, - stages: gl.VueStages, - commit: gl.CommitComponent, - pipelineUrl: gl.VuePipelineUrl, - pipelineHead: gl.VuePipelineHead, - glPagination: gl.VueGlPagination, - statusScope: gl.VueStatusScope, - timeAgo: gl.VueTimeAgo, + 'gl-pagination': gl.VueGlPagination, + 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, }, + data() { return { pipelines: [], @@ -38,87 +38,29 @@ change(pagenum, apiScope) { gl.utils.visitUrl(`?scope=${apiScope}&p=${pagenum}`); }, - author(pipeline) { - if (!pipeline.commit) return { avatar_url: '', web_url: '', username: '' }; - if (pipeline.commit.author) return pipeline.commit.author; - return { - avatar_url: pipeline.commit.author_gravatar_url, - web_url: `mailto:${pipeline.commit.author_email}`, - username: pipeline.commit.author_name, - }; - }, - ref(pipeline) { - const { ref } = pipeline; - return { name: ref.name, tag: ref.tag, ref_url: ref.path }; - }, - commitTitle(pipeline) { - return pipeline.commit ? pipeline.commit.title : ''; - }, - commitSha(pipeline) { - return pipeline.commit ? pipeline.commit.short_id : ''; - }, - commitUrl(pipeline) { - return pipeline.commit ? pipeline.commit.commit_path : ''; - }, - match(string) { - return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase()); - }, }, template: ` <div> - <div class="pipelines realtime-loading" v-if='pipelines.length < 1'> + <div class="pipelines realtime-loading" v-if='pageRequest'> <i class="fa fa-spinner fa-spin"></i> </div> - <div class="table-holder" v-if='pipelines.length'> - <table class="table ci-table"> - <thead> - <tr> - <th class="pipeline-status">Status</th> - <th class="pipeline-info">Pipeline</th> - <th class="pipeline-commit">Commit</th> - <th class="pipeline-stages">Stages</th> - <th class="pipeline-date"></th> - <th class="pipeline-actions hidden-xs"></th> - </tr> - </thead> - <tbody> - <tr class="commit" v-for='pipeline in pipelines'> - <status-scope - :pipeline='pipeline' - :match='match' - :svgs='svgs' - > - </status-scope> - <pipeline-url :pipeline='pipeline'></pipeline-url> - <td> - <commit - :commit-icon-svg='svgs.commitIconSvg' - :author='author(pipeline)' - :tag="pipeline.ref.tag" - :title='commitTitle(pipeline)' - :commit-ref='ref(pipeline)' - :short-sha='commitSha(pipeline)' - :commit-url='commitUrl(pipeline)' - > - </commit> - </td> - <stages - :pipeline='pipeline' - :svgs='svgs' - :match='match' - > - </stages> - <time-ago :pipeline='pipeline' :svgs='svgs'></time-ago> - <pipeline-actions :pipeline='pipeline' :svgs='svgs'></pipeline-actions> - </tr> - </tbody> - </table> + + <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="pipelines realtime-loading" v-if='pageRequest'> - <i class="fa fa-spinner fa-spin"></i> + + <div class="table-holder" v-if='!pageRequest && pipelines.length'> + <pipelines-table-component + :pipelines='pipelines' + :svgs='svgs'> + </pipelines-table-component> </div> + <gl-pagination - v-if='pageInfo.total > pageInfo.perPage' + v-if='!pageRequest && pipelines.length && pageInfo.total > pageInfo.perPage' :pagenum='pagenum' :change='change' :count='count.all' diff --git a/app/assets/javascripts/vue_pipelines_index/stage.js.es6 b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 index 496df9aaced..8cc417a9966 100644 --- a/app/assets/javascripts/vue_pipelines_index/stage.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 @@ -15,7 +15,7 @@ required: true, }, svgs: { - type: DOMStringMap, + type: Object, required: true, }, match: { diff --git a/app/assets/javascripts/vue_pipelines_index/stages.js.es6 b/app/assets/javascripts/vue_pipelines_index/stages.js.es6 deleted file mode 100644 index cb176b3f0c6..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/stages.js.es6 +++ /dev/null @@ -1,21 +0,0 @@ -/* global Vue, gl */ -/* eslint-disable no-param-reassign */ - -((gl) => { - gl.VueStages = Vue.extend({ - components: { - 'vue-stage': gl.VueStage, - }, - props: ['pipeline', 'svgs', 'match'], - template: ` - <td class="stage-cell"> - <div - class="stage-container dropdown js-mini-pipeline-graph" - v-for='stage in pipeline.details.stages' - > - <vue-stage :stage='stage' :svgs='svgs' :match='match'></vue-stage> - </div> - </td> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/store.js.es6 b/app/assets/javascripts/vue_pipelines_index/store.js.es6 index 0f5ce2a9274..0ee21f00fdc 100644 --- a/app/assets/javascripts/vue_pipelines_index/store.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/store.js.es6 @@ -20,6 +20,7 @@ require('../vue_realtime_listener'); gl.PipelineStore = class { fetchDataLoop(Vue, pageNum, url, apiScope) { + this.pageRequest = true; const updatePipelineNums = (count) => { const { all } = count; const running = count.running_or_pending; @@ -41,16 +42,18 @@ require('../vue_realtime_listener'); this.pageRequest = false; }, () => { this.pageRequest = false; - return new Flash('Something went wrong on our end.'); + return new Flash('An error occurred while fetching the pipelines, please reload the page again.'); }); goFetch(); const startTimeLoops = () => { this.timeLoopInterval = setInterval(() => { - this.$children - .filter(e => e.$options._componentTag === 'time-ago') - .forEach(e => e.changeTime()); + this.$children[0].$children.reduce((acc, component) => { + const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0]; + acc.push(timeAgoComponent); + return acc; + }, []).forEach(e => e.changeTime()); }, 10000); }; diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 index 655110feba1..3598da11573 100644 --- a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 @@ -1,6 +1,9 @@ /* global Vue, gl */ /* eslint-disable no-param-reassign */ +window.Vue = require('vue'); +require('../lib/utils/datetime_utility'); + ((gl) => { gl.VueTimeAgo = Vue.extend({ data() { diff --git a/app/assets/javascripts/vue_realtime_listener/index.js.es6 b/app/assets/javascripts/vue_realtime_listener/index.js.es6 index 95564152cce..30f6680a673 100644 --- a/app/assets/javascripts/vue_realtime_listener/index.js.es6 +++ b/app/assets/javascripts/vue_realtime_listener/index.js.es6 @@ -14,5 +14,16 @@ window.addEventListener('focus', startIntervals); window.addEventListener('blur', removeIntervals); document.addEventListener('beforeunload', removeAll); + + // add removeAll methods to stack + const stack = gl.VueRealtimeListener.reset; + gl.VueRealtimeListener.reset = () => { + gl.VueRealtimeListener.reset = stack; + removeAll(); + stack(); + }; }; + + // remove all event listeners and intervals + gl.VueRealtimeListener.reset = () => undefined; // noop })(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_common_component/commit.js.es6 b/app/assets/javascripts/vue_shared/components/commit.js.es6 index 4adad7bea31..7f7c18ddeb1 100644 --- a/app/assets/javascripts/vue_common_component/commit.js.es6 +++ b/app/assets/javascripts/vue_shared/components/commit.js.es6 @@ -1,7 +1,5 @@ /* global Vue */ -window.Vue = require('vue'); - (() => { window.gl = window.gl || {}; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 b/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 new file mode 100644 index 00000000000..4bdaef31ee9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 @@ -0,0 +1,61 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +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: () => ([]), + }, + + /** + * TODO: Remove this when we have webpack. + */ + svgs: { + type: Object, + required: true, + default: () => ({}), + }, + }, + + components: { + 'pipelines-table-row-component': gl.pipelines.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 hidden-xs"></th> + </tr> + </thead> + <tbody> + <template v-for="model in pipelines" + v-bind:model="model"> + <tr is="pipelines-table-row-component" + :pipeline="model" + :svgs="svgs"></tr> + </template> + </tbody> + </table> + `, + }); +})(); diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 new file mode 100644 index 00000000000..61c1b72d9d2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 @@ -0,0 +1,234 @@ +/* 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'); +/** + * 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: () => ({}), + }, + + /** + * TODO: Remove this when we have webpack; + */ + svgs: { + type: Object, + required: true, + default: () => ({}), + }, + }, + + 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, + }, + + 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; + }, + + /** + * 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; + }, {}); + } + + 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; + }, + }, + + methods: { + /** + * FIXME: This should not be in this component but in the components that + * need this function. + * + * Used to render SVGs in the following components: + * - status-scope + * - dropdown-stage + * + * @param {String} string + * @return {String} + */ + match(string) { + return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase()); + }, + }, + + template: ` + <tr class="commit"> + <status-scope + :pipeline="pipeline" + :svgs="svgs" + :match="match"> + </status-scope> + + <pipeline-url :pipeline="pipeline"></pipeline-url> + + <td> + <commit-component + :tag="commitTag" + :commit-ref="commitRef" + :commit-url="commitUrl" + :short-sha="commitShortSha" + :title="commitTitle" + :author="commitAuthor" + :commit-icon-svg="svgs.commitIconSvg"> + </commit-component> + </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" + :svgs="svgs" + :match="match"> + </dropdown-stage> + </div> + </td> + + <time-ago :pipeline="pipeline" :svgs="svgs"></time-ago> + + <pipeline-actions :pipeline="pipeline" :svgs="svgs"></pipeline-actions> + </tr> + `, + }); +})(); diff --git a/app/assets/javascripts/vue_pagination/index.js.es6 b/app/assets/javascripts/vue_shared/components/table_pagination.js.es6 index 67c6cb73761..67c6cb73761 100644 --- a/app/assets/javascripts/vue_pagination/index.js.es6 +++ b/app/assets/javascripts/vue_shared/components/table_pagination.js.es6 diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 new file mode 100644 index 00000000000..d3229f9f730 --- /dev/null +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 @@ -0,0 +1,23 @@ +/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars, +no-param-reassign, no-plusplus */ +/* global Vue */ + +Vue.http.interceptors.push((request, next) => { + Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; + + next((response) => { + if (typeof response.data === 'string') { + response.data = JSON.parse(response.data); + } + + Vue.activeResources--; + }); +}); + +Vue.http.interceptors.push((request, next) => { + // needed in order to not break the tests. + if ($.rails) { + request.headers['X-CSRF-Token'] = $.rails.csrfToken(); + } + next(); +}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 8b93665d085..1dcd1f8a6fc 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -2,7 +2,6 @@ * This is a manifest file that'll automatically include all the stylesheets available in this directory * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at * the top of the compiled file, but it's generally better to create a new file per style scope. - *= require jquery-ui/datepicker *= require jquery-ui/autocomplete *= require jquery.atwho *= require select2 @@ -19,6 +18,8 @@ * directory. */ +@import "../../../node_modules/pikaday/scss/pikaday"; + /* * GitLab UI framework */ diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 0a26b4c6a8c..0ca5a9343f7 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -128,8 +128,7 @@ .note-action-button .link-highlight, .toolbar-btn, -.dropdown-toggle-caret, -.fa:not(.fa-bell) { +.dropdown-toggle-caret { @include transition(color); } diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 1d59700543c..3f5b78ed445 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -28,6 +28,8 @@ .avatar { @extend .avatar-circle; + @include transition-property(none); + width: 40px; height: 40px; padding: 0; diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 1d2d1bfc0d7..fb8ea18d122 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -9,6 +9,8 @@ } .user-calendar-activities { + direction: ltr; + .str-truncated { max-width: 70%; } @@ -43,3 +45,56 @@ float: right; font-size: 12px; } + +.pika-single.gitlab-theme { + .pika-label { + color: $gl-text-color-secondary; + font-size: 14px; + font-weight: normal; + } + + th { + padding: 2px 0; + color: $note-disabled-comment-color; + font-weight: normal; + text-transform: lowercase; + border-top: 1px solid $calendar-border-color; + } + + abbr { + cursor: default; + } + + td { + border: 1px solid $calendar-border-color; + + &:first-child { + border-left: 0; + } + + &:last-child { + border-right: 0; + } + } + + .pika-day { + border-radius: 0; + background-color: $white-light; + text-align: center; + } + + .is-today { + .pika-day { + color: inherit; + font-weight: normal; + } + } + + .is-selected .pika-day, + .pika-day:hover, + .is-today .pika-day:hover { + background: $gl-primary; + color: $white-light; + box-shadow: none; + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 0ce94a26a7f..a4b38723bbd 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -253,6 +253,8 @@ li.note { .progress { margin-bottom: 0; margin-top: 4px; + box-shadow: none; + background-color: $border-gray-light; } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ca5861bf3e6..ff31e7f7b3d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -125,7 +125,6 @@ top: 100%; left: 0; z-index: 9; - max-width: 280px; min-width: 240px; margin-top: 2px; margin-bottom: 0; @@ -137,6 +136,10 @@ border-radius: $border-radius-base; box-shadow: 0 2px 4px $dropdown-shadow-color; + .filtered-search-input-container & { + max-width: 280px; + } + &.is-loading { .dropdown-content { display: none; @@ -502,119 +505,16 @@ max-height: 230px; } - .ui-widget { - table { - margin: 0; - } - - &.ui-datepicker-inline { - padding: 0 10px; - border: 0; - width: 100%; - } - - .ui-datepicker-header { - padding: 0 8px 10px; - border: 0; - - .ui-icon { - background: none; - font-size: 20px; - text-indent: 0; - - &::before { - display: block; - position: relative; - top: -2px; - color: $dropdown-title-btn-color; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - } - } - - .ui-datepicker-calendar { - .ui-state-hover, - .ui-state-active { - color: $white-light; - border: 0; - } - } - - .ui-datepicker-prev, - .ui-datepicker-next { - top: 0; - height: 15px; - cursor: pointer; - - &:hover { - background-color: transparent; - border: 0; - - .ui-icon::before { - color: $md-link-color; - } - } - } - - .ui-datepicker-prev { - left: 0; - - .ui-icon::before { - content: '\f104'; - text-align: left; - } - } - - .ui-datepicker-next { - right: 0; - - .ui-icon::before { - content: '\f105'; - text-align: right; - } - } - - td { - padding: 0; - border: 1px solid $calendar-border-color; - - &:first-child { - border-left: 0; - } - - &:last-child { - border-right: 0; - } - - a { - line-height: 17px; - border: 0; - border-radius: 0; - } - } - - .ui-datepicker-title { - color: $gl-text-color; - font-size: 14px; - line-height: 1; - font-weight: normal; - } - } - - th { - padding: 2px 0; - color: $note-disabled-comment-color; - font-weight: normal; - text-transform: lowercase; - border-top: 1px solid $calendar-border-color; + .pika-single { + position: relative!important; + top: 0!important; + border: 0; + box-shadow: none; } - .ui-datepicker-unselectable { - background-color: $gray-light; + .pika-lendar { + margin-top: -5px; + margin-bottom: 0; } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index c51912b4ac4..30f242a35db 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -231,3 +231,46 @@ span.idiff { } } } + +.file-title-flex-parent { + display: flex; + align-items: center; + justify-content: space-between; + background-color: $gray-light; + border-bottom: 1px solid $border-color; + padding: 5px $gl-padding; + margin: 0; + border-radius: 3px 3px 0 0; + + .file-header-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 30px; + position: relative; + } + + .btn-clipboard { + position: absolute; + right: 0; + } + + a { + color: $gl-text-color; + } + + small { + margin: 0 10px 0 0; + } + + .file-actions { + white-space: nowrap; + + .btn { + padding: 0 10px; + font-size: 13px; + line-height: 28px; + display: inline-block; + } + } +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 2a01bc4d44d..34e010e0e8a 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -222,6 +222,10 @@ header { float: right; border-top: none; + @media (min-width: $screen-md-min) { + padding: 0; + } + @media (max-width: $screen-xs-max) { float: none; } @@ -272,7 +276,7 @@ header { .header-user { .dropdown-menu-nav { - width: 140px; + min-width: 140px; margin-top: -5px; } } diff --git a/app/assets/stylesheets/framework/jquery.scss b/app/assets/stylesheets/framework/jquery.scss index 18f2f316f02..d335fedefe2 100644 --- a/app/assets/stylesheets/framework/jquery.scss +++ b/app/assets/stylesheets/framework/jquery.scss @@ -2,42 +2,6 @@ font-family: $regular_font; font-size: $font-size-base; - &.ui-datepicker, - &.ui-datepicker-inline { - border: 1px solid $jq-ui-border; - padding: 10px; - width: 270px; - - .ui-datepicker-header { - background: $white-light; - border-color: $jq-ui-border; - - .ui-datepicker-prev, - .ui-datepicker-next { - top: 4px; - } - - .ui-datepicker-prev { - left: 2px; - } - - .ui-datepicker-next { - right: 2px; - } - - .ui-state-hover { - background: transparent; - border: 0; - cursor: pointer; - } - } - - .ui-datepicker-calendar td a { - padding: 5px; - text-align: center; - } - } - &.ui-autocomplete { border-color: $jq-ui-border; padding: 0; @@ -59,25 +23,4 @@ border: 0; background: transparent; } - - .ui-datepicker-calendar { - .ui-state-active, - .ui-state-hover, - .ui-state-focus { - border: 1px solid $gl-primary; - background: $gl-primary; - color: $white-light; - } - } -} - -.ui-sortable-handle { - cursor: move; - cursor: -webkit-grab; - cursor: -moz-grab; - - &:active { - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 426596027de..2bfdb9f9601 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -307,3 +307,7 @@ ul.controls { } } } + +ul.indent-list { + padding: 10px 0 0 30px; +} diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index fd081c2d7e1..674d3bb45aa 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -283,10 +283,7 @@ } .layout-nav { - position: fixed; - top: $header-height; width: 100%; - z-index: 11; background: $gray-light; border-bottom: 1px solid $border-color; transition: padding $sidebar-transition-duration; @@ -419,15 +416,20 @@ } .page-with-layout-nav { - margin-top: $header-height + 2; - .right-sidebar { top: ($header-height * 2) + 2; } + + .build-sidebar { + top: ($header-height * 3) + 3; + + &.affix { + top: 0; + } + } } .activities { - .nav-block { border-bottom: 1px solid $border-color; diff --git a/app/assets/stylesheets/framework/pagination.scss b/app/assets/stylesheets/framework/pagination.scss index b37c1d0d670..c3ec9db0f07 100644 --- a/app/assets/stylesheets/framework/pagination.scss +++ b/app/assets/stylesheets/framework/pagination.scss @@ -6,8 +6,22 @@ .pagination { padding: 0; + + a { + cursor: pointer; + } + + .separator, + .separator:hover { + a { + cursor: default; + background-color: $gray-light; + padding: $gl-vert-padding; + } + } } + .gap, .gap:hover { background-color: $gray-light; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index f0b03710c79..20bcb1eeb23 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -1,5 +1,5 @@ .page-with-sidebar { - padding: $header-height 0 25px; + padding-bottom: 25px; transition: padding $sidebar-transition-duration; &.page-sidebar-pinned { @@ -208,7 +208,9 @@ header.header-sidebar-pinned { padding-right: 0; @media (min-width: $screen-sm-min) { - padding-right: $sidebar_collapsed_width; + .content-wrapper { + padding-right: $sidebar_collapsed_width; + } .merge-request-tabs-holder.affix { right: $sidebar_collapsed_width; @@ -234,7 +236,9 @@ header.header-sidebar-pinned { } @media (min-width: $screen-md-min) { - padding-right: $gutter_width; + .content-wrapper { + padding-right: $gutter_width; + } &:not(.with-overlay) .merge-request-tabs-holder.affix { right: $gutter_width; @@ -252,4 +256,9 @@ header.header-sidebar-pinned { .right-sidebar { border-left: 1px solid $border-color; + + &.affix { + position: fixed; + top: 0; + } } diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss index 60ff72c703e..ea40f449134 100644 --- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss +++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss @@ -138,6 +138,13 @@ pre { margin: 0; } +blockquote { + color: $gl-grayish-blue; + padding: 0 0 0 15px; + margin: 0; + border-left: 3px solid $white-dark; +} + span.highlight_word { background-color: $highlighted-highlight-word !important; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index b362cc758cc..9a36d76136b 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -298,12 +298,8 @@ .issue-boards-sidebar { &.right-sidebar { - top: 153px; + top: 0; bottom: 0; - - @media (min-width: $screen-sm-min) { - top: 220px; - } } .issuable-sidebar-header { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index fef8e8eec27..c3d45d708c1 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -159,7 +159,6 @@ .commit-row-description { font-size: 14px; - border-left: 1px solid $white-normal; padding: 10px 15px; margin: 10px 0; background: $gray-light; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index cda069e6c0e..5b777953fb0 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -284,7 +284,11 @@ .events-description { line-height: 65px; - padding-left: $gl-padding; + padding: 0 $gl-padding; + } + + .events-info { + color: $gl-text-color-secondary; } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 96ba7c40634..92d7772da57 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -34,9 +34,14 @@ } } - .file-title { + .file-title, + .file-title-flex-parent { cursor: pointer; + a:hover { + text-decoration: none; + } + &:hover { background-color: $gray-normal; } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index b989d72ce1c..5776d86983a 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -41,7 +41,6 @@ word-wrap: break-word; .md { - color: $gl-grayish-blue; font-size: $gl-font-size; .label { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 4ef95d27f4f..a53cc27fac9 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -189,11 +189,10 @@ } .right-sidebar { - position: fixed; + position: absolute; top: $header-height; bottom: 0; right: 0; - z-index: 10; transition: width .3s; background: $gray-light; padding: 10px 20px; @@ -461,8 +460,19 @@ .issuable-list { li { + + .issue-box { + display: -webkit-flex; + display: flex; + } + + .issue-info-container { + -webkit-flex: 1; + flex: 1; + padding-right: $gl-padding; + } + .issue-check { - float: left; padding-right: $gl-padding; margin-bottom: 10px; min-width: 15px; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8734a3b1598..80b0c9493d8 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -1,6 +1,6 @@ .issues-list { .issue { - padding: 10px $gl-padding; + padding: 10px 0 10px $gl-padding; position: relative; .title { @@ -148,3 +148,7 @@ ul.related-merge-requests > li { border: 1px solid $border-gray-normal; } } + +.recaptcha { + margin-bottom: 30px; +} diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 21d9b4c54ea..e1ef0b029a5 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -116,6 +116,22 @@ } .manage-labels-list { + > li:not(.empty-message) { + background-color: $white-light; + cursor: move; + cursor: -webkit-grab; + cursor: -moz-grab; + + &:active { + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + } + + &.sortable-ghost { + opacity: 0.3; + } + } + .btn-action { color: $gl-text-color; @@ -259,3 +275,8 @@ } } } + +.label-link { + display: inline-block; + vertical-align: text-top; +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 0c013915a63..0b0c4bc130d 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -80,19 +80,28 @@ .ci_widget { border-bottom: 1px solid $well-inner-border; color: $gl-text-color; + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + align-items: center; + + i, + svg { + margin-right: 8px; + } svg { - margin-right: 4px; position: relative; top: 1px; overflow: visible; } - &.ci-success_with_warnings { + & > span { + padding-right: 4px; + } - i { - color: $gl-warning; - } + @media (max-width: $screen-xs-max) { + flex-wrap: wrap; } } @@ -102,6 +111,43 @@ padding: $gl-padding; } + .mr-widget-pipeline-graph { + flex-shrink: 0; + + .dropdown-menu { + margin-top: 11px; + } + + .ci-action-icon-wrapper { + line-height: 16px; + } + + @media (min-width: $screen-sm-min) { + .stage-cell { + padding: 0 4px; + } + } + + @media (max-width: $screen-xs-max) { + order: 1; + margin-top: $gl-padding-top; + border-radius: 3px; + background-color: $white-light; + border: 1px solid $gray-darker; + width: 100%; + text-align: center; + + .dropdown-menu { + margin-left: -97.5px; + } + + .arrow-up::before, + .arrow-up::after, { + margin-left: 97.5px; + } + } + } + .normal { color: $gl-text-color; } @@ -223,8 +269,15 @@ .mr-list { .merge-request { - padding: 10px 15px; + padding: 10px 0 10px 15px; position: relative; + display: -webkit-flex; + display: flex; + + .issue-info-container { + -webkit-flex: 1; + flex: 1; + } .merge-request-title { margin-bottom: 2px; @@ -430,7 +483,7 @@ background-color: $white-light; &.affix { - top: 100px; + top: 0; left: 0; z-index: 10; transition: right .15s; diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 686b64cdd24..3da1150f89b 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -178,3 +178,9 @@ } } } + +.issuable-row { + background-color: $white-light; + cursor: -webkit-grab; + cursor: grab; +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 367a468e1ba..00eb5b30fd5 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -94,6 +94,10 @@ padding: 10px 8px; } + td.stage-cell { + padding: 10px 0; + } + .commit-link { padding: 9px 8px 10px; } @@ -183,52 +187,11 @@ } } - .stage-cell { - font-size: 0; - padding: 10px 4px; - - > .stage-container > div > button > span > svg, - > .stage-container > button > svg { - height: 22px; - width: 22px; - position: absolute; - top: -1px; - left: -1px; - z-index: 2; - overflow: visible; - } - - .stage-container { - display: inline-block; - position: relative; - height: 22px; - margin: 3px 6px 3px 0; - - .tooltip { - white-space: nowrap; - } - - .tooltip-inner { - padding: 3px 4px; - } - - &:not(:last-child) { - &::after { - content: ''; - width: 7px; - position: absolute; - right: -7px; - top: 10px; - border-bottom: 2px solid $border-color; - } - } - } - } - .duration, .finished-at { color: $gl-text-color-secondary; margin: 4px 0; + white-space: nowrap; .fa { font-size: 12px; @@ -311,6 +274,50 @@ } } +.stage-cell { + font-size: 0; + padding: 10px 4px; + + > .stage-container > div > button > span > svg, + > .stage-container > button > svg { + height: 22px; + width: 22px; + position: absolute; + top: -1px; + left: -1px; + z-index: 2; + overflow: visible; + } + + .stage-container { + display: inline-block; + position: relative; + height: 22px; + margin: 3px 6px 3px 0; + + // Hack to show a button tooltip inline + button.has-tooltip + .tooltip { + min-width: 105px; + } + + // Bootstrap way of showing the content inline for anchors. + a.has-tooltip { + white-space: nowrap; + } + + &:not(:last-child) { + &::after { + content: ''; + width: 7px; + position: absolute; + right: -7px; + top: 10px; + border-bottom: 2px solid $border-color; + } + } + } +} + .admin-builds-table { .ci-table td:last-child { min-width: 120px; @@ -666,7 +673,7 @@ vertical-align: bottom; display: inline-block; position: relative; - font-weight: 200; + font-weight: normal; } // Dropdown button in mini pipeline graph @@ -857,7 +864,7 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - width: 90px; + max-width: 70%; color: $gl-text-color-secondary; margin-left: 2px; display: inline-block; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 722b3006f7c..8031c4467a4 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -201,10 +201,6 @@ color: $note-disabled-comment-color; } -.datepicker.personal-access-tokens-expires-at .ui-state-disabled span { - text-align: center; -} - .created-personal-access-token-container { #created-personal-access-token { width: 90%; diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index d5783e14b21..9bc47bbe173 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -1,3 +1,11 @@ +.new-wiki-page { + .new-wiki-page-slug-tip { + display: inline-block; + max-width: 100%; + margin-top: 5px; + } +} + .title .edit-wiki-header { width: 780px; margin-left: auto; @@ -9,12 +17,18 @@ @extend .top-area; position: relative; + .wiki-breadcrumb { + border-bottom: 1px solid $white-normal; + padding: 11px 0; + } + .wiki-page-title { margin: 0; font-size: 22px; } .wiki-last-edit-by { + display: block; color: $gl-text-color-secondary; strong { @@ -121,6 +135,10 @@ margin: 5px 0 10px; } + ul.wiki-pages ul { + padding-left: 15px; + } + .wiki-sidebar-header { padding: 0 $gl-padding $gl-padding; @@ -129,3 +147,15 @@ } } } + +ul.wiki-pages-list.content-list { + & ul { + list-style: none; + margin-left: 0; + padding-left: 15px; + } + + & ul li { + padding: 5px 0; + } +} diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index c491e5c7550..8360ce08bdc 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -1,7 +1,7 @@ class Admin::DashboardController < Admin::ApplicationController def index - @projects = Project.limit(10) + @projects = Project.with_route.limit(10) @users = User.limit(10) - @groups = Group.limit(10) + @groups = Group.with_route.limit(10) end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index b7722a1d15d..cea3d088e94 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -2,7 +2,7 @@ class Admin::GroupsController < Admin::ApplicationController before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update] def index - @groups = Group.with_statistics + @groups = Group.with_statistics.with_route @groups = @groups.sort(@sort = params[:sort]) @groups = @groups.search(params[:name]) if params[:name].present? @groups = @groups.page(params[:page]) @@ -49,7 +49,7 @@ class Admin::GroupsController < Admin::ApplicationController end def destroy - DestroyGroupService.new(@group, current_user).async_execute + Groups::DestroyService.new(@group, current_user).async_execute redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion." end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index aa0f8d434dc..1cd50852e89 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -175,7 +175,7 @@ class Admin::UsersController < Admin::ApplicationController def user_params_ce [ - :admin, + :access_level, :avatar, :bio, :can_create_group, diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bb47e2a8bf7..bf6be3d516b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,7 +12,6 @@ class ApplicationController < ActionController::Base before_action :authenticate_user_from_private_token! before_action :authenticate_user! before_action :validate_user_service_ticket! - before_action :reject_blocked! before_action :check_password_expiration before_action :check_2fa_requirement before_action :ldap_security_check @@ -87,22 +86,8 @@ class ApplicationController < ActionController::Base logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}" end - def reject_blocked! - if current_user && current_user.blocked? - sign_out current_user - flash[:alert] = "Your account is blocked. Retry when an admin has unblocked it." - redirect_to new_user_session_path - end - end - def after_sign_in_path_for(resource) - if resource.is_a?(User) && resource.respond_to?(:blocked?) && resource.blocked? - sign_out resource - flash[:alert] = "Your account is blocked. Retry when an admin has unblocked it." - new_user_session_path - else - stored_location_for(:redirect) || stored_location_for(resource) || root_path - end + stored_location_for(:redirect) || stored_location_for(resource) || root_path end def after_sign_out_path_for(resource) diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 6247934f81e..a6e158ebae6 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -9,6 +9,28 @@ module IssuableCollections private + def issuable_meta_data(issuable_collection) + # map has to be used here since using pluck or select will + # throw an error when ordering issuables by priority which inserts + # a new order into the collection. + # We cannot use reorder to not mess up the paginated collection. + issuable_ids = issuable_collection.map(&:id) + issuable_note_count = Note.count_for_collection(issuable_ids, @collection_type) + issuable_votes_count = AwardEmoji.votes_for_collection(issuable_ids, @collection_type) + + issuable_ids.each_with_object({}) do |id, issuable_meta| + downvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.downvote? } + upvotes = issuable_votes_count.find { |votes| votes.awardable_id == id && votes.upvote? } + notes = issuable_note_count.find { |notes| notes.noteable_id == id } + + issuable_meta[id] = Issuable::IssuableMeta.new( + upvotes.try(:count).to_i, + downvotes.try(:count).to_i, + notes.try(:count).to_i + ) + end + end + def issues_collection issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) end diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index b46adcceb60..fb5edb34370 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -9,6 +9,9 @@ module IssuesAction .non_archived .page(params[:page]) + @collection_type = "Issue" + @issuable_meta_data = issuable_meta_data(@issues) + respond_to do |format| format.html format.atom { render layout: false } diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb index fdb05bb3228..6229759dcf1 100644 --- a/app/controllers/concerns/merge_requests_action.rb +++ b/app/controllers/concerns/merge_requests_action.rb @@ -7,6 +7,9 @@ module MergeRequestsAction @merge_requests = merge_requests_collection .page(params[:page]) + + @collection_type = "MergeRequest" + @issuable_meta_data = issuable_meta_data(@merge_requests) end private diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 562f92bd83c..a6891149bfa 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -1,6 +1,8 @@ module SpammableActions extend ActiveSupport::Concern + include Recaptcha::Verify + included do before_action :authorize_submit_spammable!, only: :mark_as_spam end @@ -15,6 +17,15 @@ module SpammableActions private + def recaptcha_params + return {} unless params[:recaptcha_verification] && Gitlab::Recaptcha.load_configurations! && verify_recaptcha + + { + recaptcha_verified: true, + spam_log_id: params[:spam_log_id] + } + end + def spammable raise NotImplementedError, "#{self.class} does not implement #{__method__}" end @@ -22,4 +33,11 @@ module SpammableActions def authorize_submit_spammable! access_denied! unless current_user.admin? end + + def render_recaptcha? + return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors + return false unless Gitlab::Recaptcha.enabled? + + spammable.spam + end end diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb index de6bc689bb7..0b7cf8167f0 100644 --- a/app/controllers/dashboard/groups_controller.rb +++ b/app/controllers/dashboard/groups_controller.rb @@ -1,5 +1,5 @@ class Dashboard::GroupsController < Dashboard::ApplicationController def index - @group_members = current_user.group_members.includes(:source).page(params[:page]) + @group_members = current_user.group_members.includes(source: :route).page(params[:page]) end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 3ba8c2f8bb9..325ae565537 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -1,19 +1,14 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController include FilterProjects - before_action :event_filter - def index - @projects = current_user.authorized_projects.sorted_by_activity - @projects = filter_projects(@projects) - @projects = @projects.includes(:namespace) + @projects = load_projects(current_user.authorized_projects) @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]) respond_to do |format| format.html { @last_push = current_user.recent_push } format.atom do - event_filter load_events render layout: false end @@ -26,9 +21,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end def starred - @projects = current_user.viewable_starred_projects.sorted_by_activity - @projects = filter_projects(@projects) - @projects = @projects.includes(:namespace, :forked_from_project, :tags) + @projects = load_projects(current_user.viewable_starred_projects) + @projects = @projects.includes(:forked_from_project, :tags) @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]) @@ -37,7 +31,6 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController respond_to do |format| format.html - format.json do render json: { html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) @@ -48,9 +41,15 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController private + def load_projects(base_scope) + projects = base_scope.sorted_by_activity.includes(:namespace) + + filter_projects(projects) + end + def load_events - @events = Event.in_projects(@projects) - @events = @event_filter.apply_filter(@events).with_associations + @events = Event.in_projects(load_projects(current_user.authorized_projects)) + @events = event_filter.apply_filter(@events).with_associations @events = @events.limit(20).offset(params[:offset] || 0) end end diff --git a/app/controllers/explore/application_controller.rb b/app/controllers/explore/application_controller.rb index a1ab8b99048..baf54520b9c 100644 --- a/app/controllers/explore/application_controller.rb +++ b/app/controllers/explore/application_controller.rb @@ -1,5 +1,5 @@ class Explore::ApplicationController < ApplicationController - skip_before_action :authenticate_user!, :reject_blocked! + skip_before_action :authenticate_user! layout 'explore' end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 4f273a8d4f0..0cbf3eb58a3 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -9,7 +9,7 @@ class Groups::GroupMembersController < Groups::ApplicationController @sort = params[:sort].presence || sort_value_name @project = @group.projects.find(params[:project_id]) if params[:project_id] - @members = @group.group_members + @members = GroupMembersFinder.new(@group).execute @members = @members.non_invite unless can?(current_user, :admin_group, @group) @members = @members.search(params[:search]) if params[:search].present? @members = @members.sort(@sort) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 264b14713fb..7ed54479599 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -13,9 +13,11 @@ class GroupsController < Groups::ApplicationController before_action :authorize_create_group!, only: [:new, :create] # Load group projects - before_action :group_projects, only: [:show, :projects, :activity, :issues, :merge_requests] + before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] before_action :event_filter, only: [:activity] + before_action :user_actions, only: [:show, :subgroups] + layout :determine_layout def index @@ -37,13 +39,6 @@ class GroupsController < Groups::ApplicationController end def show - if current_user - @last_push = current_user.recent_push - @notification_setting = current_user.notification_settings_for(group) - end - - @nested_groups = group.children - setup_projects respond_to do |format| @@ -62,6 +57,11 @@ class GroupsController < Groups::ApplicationController end end + def subgroups + @nested_groups = group.children + @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present? + end + def activity respond_to do |format| format.html @@ -91,7 +91,7 @@ class GroupsController < Groups::ApplicationController end def destroy - DestroyGroupService.new(@group, current_user).async_execute + Groups::DestroyService.new(@group, current_user).async_execute redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion." end @@ -99,13 +99,16 @@ class GroupsController < Groups::ApplicationController protected def setup_projects + options = {} + options[:only_owned] = true if params[:shared] == '0' + options[:only_shared] = true if params[:shared] == '1' + + @projects = GroupProjectsFinder.new(group, options).execute(current_user) @projects = @projects.includes(:namespace) @projects = @projects.sorted_by_activity @projects = filter_projects(@projects) @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]) if params[:filter_projects].blank? - - @shared_projects = GroupProjectsFinder.new(group, only_shared: true).execute(current_user) end def authorize_create_group! @@ -138,7 +141,8 @@ class GroupsController < Groups::ApplicationController :public, :request_access_enabled, :share_with_group_lock, - :visibility_level + :visibility_level, + :parent_id ] end @@ -147,4 +151,11 @@ class GroupsController < Groups::ApplicationController @events = event_filter.apply_filter(@events).with_associations @events = @events.limit(20).offset(params[:offset] || 0) end + + def user_actions + if current_user + @last_push = current_user.recent_push + @notification_setting = current_user.notification_settings_for(group) + end + end end diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 37feff79999..87c0f8905ff 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -1,5 +1,5 @@ class HelpController < ApplicationController - skip_before_action :authenticate_user!, :reject_blocked! + skip_before_action :authenticate_user! layout 'help' diff --git a/app/controllers/koding_controller.rb b/app/controllers/koding_controller.rb index f3759b4c0ea..6b1e64ce819 100644 --- a/app/controllers/koding_controller.rb +++ b/app/controllers/koding_controller.rb @@ -1,5 +1,5 @@ class KodingController < ApplicationController - before_action :check_integration!, :authenticate_user!, :reject_blocked! + before_action :check_integration! layout 'koding' def index diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index b8b71d295f6..a271e2dfc4b 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) + params.require(:user).permit(:notification_email, :notified_of_own_activity) end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 9940263ae24..a1db856dcfb 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -30,6 +30,8 @@ class Projects::BlobController < Projects::ApplicationController end def show + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last end def edit @@ -59,10 +61,10 @@ class Projects::BlobController < Projects::ApplicationController end def destroy - create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.", - success_path: namespace_project_tree_path(@project.namespace, @project, @target_branch), - failure_view: :show, - failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) + create_commit(Files::DestroyService, success_notice: "The file has been successfully deleted.", + success_path: namespace_project_tree_path(@project.namespace, @project, @target_branch), + failure_view: :show, + failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) end def diff diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index b5a7078a3a1..e10d7992db7 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -37,7 +37,6 @@ class Projects::CommitController < Projects::ApplicationController format.json do render json: PipelineSerializer .new(project: @project, user: @current_user) - .with_pagination(request, response) .represent(@pipelines) end end @@ -95,6 +94,8 @@ class Projects::CommitController < Projects::ApplicationController @diffs = commit.diffs(opts) @notes_count = commit.notes.count + + @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit).execute.last end def define_note_vars diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 321cde255c3..c6651254d70 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -57,6 +57,9 @@ class Projects::CompareController < Projects::ApplicationController @diffs = @compare.diffs(diff_options) + environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @commit } + @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + @diff_notes_disabled = true @grouped_diff_discussions = {} end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 87cc36253f1..0ec8f5bd64a 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -10,7 +10,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def index @scope = params[:scope] - @environments = project.environments + @environments = project.environments.includes(:last_deployment) respond_to do |format| format.html @@ -52,10 +52,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def stop - return render_404 unless @environment.stoppable? + return render_404 unless @environment.available? - new_action = @environment.stop!(current_user) - redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, new_action]) + stop_action = @environment.stop_with_action!(current_user) + + if stop_action + redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action]) + else + redirect_to namespace_project_environment_path(project.namespace, project, @environment) + end end def terminal diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 8472ceca329..744a4af1c51 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -23,8 +23,11 @@ class Projects::IssuesController < Projects::ApplicationController respond_to :html def index - @issues = issues_collection - @issues = @issues.page(params[:page]) + @collection_type = "Issue" + @issues = issues_collection + @issues = @issues.page(params[:page]) + @issuable_meta_data = issuable_meta_data(@issues) + if @issues.out_of_range? && @issues.total_pages != 0 return redirect_to url_for(params.merge(page: @issues.total_pages)) end @@ -93,15 +96,13 @@ class Projects::IssuesController < Projects::ApplicationController def create extra_params = { request: request, merge_request_for_resolving_discussions: merge_request_for_resolving_discussions } + extra_params.merge!(recaptcha_params) + @issue = Issues::CreateService.new(project, current_user, issue_params.merge(extra_params)).execute respond_to do |format| format.html do - if @issue.valid? - redirect_to issue_path(@issue) - else - render :new - end + html_response_create end format.js do @link = @issue.attachment.url.to_js @@ -178,6 +179,20 @@ class Projects::IssuesController < Projects::ApplicationController protected + def html_response_create + if @issue.valid? + redirect_to issue_path(@issue) + elsif render_recaptcha? + if params[:recaptcha_verification] + flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' + end + + render :verify + else + render :new + end + end + def issue # The Sortable default scope causes performance issues when used with find_by @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 440259b643c..8a5a645ed0e 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -48,6 +48,10 @@ class Projects::LfsApiController < Projects::GitHttpClientController objects.each do |object| if existing_oids.include?(object[:oid]) object[:actions] = download_actions(object) + + if Guest.can?(:download_code, project) + object[:authenticated] = true + end else object[:error] = { code: 404, diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 6eb542e4bd8..c3e1760f168 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -36,8 +36,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :conflict_for_path, :resolve_conflicts] def index - @merge_requests = merge_requests_collection - @merge_requests = @merge_requests.page(params[:page]) + @collection_type = "MergeRequest" + @merge_requests = merge_requests_collection + @merge_requests = @merge_requests.page(params[:page]) + @issuable_meta_data = issuable_meta_data(@merge_requests) + if @merge_requests.out_of_range? && @merge_requests.total_pages != 0 return redirect_to url_for(params.merge(page: @merge_requests.total_pages)) end @@ -103,6 +106,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + @environment = @merge_request.environments_for(current_user).last + respond_to do |format| format.html { define_discussion_vars } format.json do @@ -216,19 +221,24 @@ class Projects::MergeRequestsController < Projects::ApplicationController end format.json do - render json: { - html: view_to_html_string('projects/merge_requests/show/_pipelines'), - pipelines: PipelineSerializer - .new(project: @project, user: @current_user) - .with_pagination(request, response) - .represent(@pipelines) - } + render json: PipelineSerializer + .new(project: @project, user: @current_user) + .represent(@pipelines) end end end def new - define_new_vars + respond_to do |format| + format.html { define_new_vars } + format.json do + define_pipelines_vars + + render json: PipelineSerializer + .new(project: @project, user: @current_user) + .represent(@pipelines) + end + end end def new_diffs @@ -245,7 +255,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end @diff_notes_disabled = true - render json: { html: view_to_html_string('projects/merge_requests/_new_diffs', diffs: @diffs) } + @environment = @merge_request.environments_for(current_user).last + + render json: { html: view_to_html_string('projects/merge_requests/_new_diffs', diffs: @diffs, environment: @environment) } end end end @@ -444,14 +456,12 @@ class Projects::MergeRequestsController < Projects::ApplicationController def ci_environments_status environments = begin - @merge_request.environments.map do |environment| - next unless can?(current_user, :read_environment, environment) - + @merge_request.environments_for(current_user).map do |environment| project = environment.project deployment = environment.first_deployment_for(@merge_request.diff_head_commit) stop_url = - if environment.stoppable? && can?(current_user, :create_deployment, environment) + if environment.stop_action? && can?(current_user, :create_deployment, environment) stop_namespace_project_environment_path(project.namespace, project, environment) end diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index c5d93ce25bc..b033f7b5ea9 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -51,7 +51,7 @@ class Projects::NotesController < Projects::ApplicationController def destroy if note.editable? - Notes::DeleteService.new(project, current_user).execute(note) + Notes::DestroyService.new(project, current_user).execute(note) end respond_to do |format| diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 53ce23221ed..c8c80551ac9 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -2,20 +2,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController before_action :authorize_admin_pipeline! def show - @ref = params[:ref] || @project.default_branch || 'master' - - @badges = [Gitlab::Badge::Build::Status, - Gitlab::Badge::Coverage::Report] - - @badges.map! do |badge| - badge.new(@project, @ref).metadata - end + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project, params: params) end def update if @project.update_attributes(update_params) flash[:notice] = "CI/CD Pipelines settings for '#{@project.name}' were successfully updated." - redirect_to namespace_project_pipelines_settings_path(@project.namespace, @project) + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) else render 'show' end diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index 9a438d5512c..2f422d352ed 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -68,8 +68,12 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def access_levels_options { - push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }, - merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } } + push_access_levels: { + "Roles" => ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }, + }, + merge_access_levels: { + "Roles" => ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } } + } } end diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index ff75c408beb..8b50ea207a5 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -5,11 +5,7 @@ class Projects::RunnersController < Projects::ApplicationController layout 'project_settings' def index - @project_runners = project.runners.ordered - @assignable_runners = current_user.ci_authorized_runners. - assignable_for(project).ordered.page(params[:page]).per(20) - @shared_runners = Ci::Runner.shared.active - @shared_runners_count = @shared_runners.count(:all) + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) end def edit @@ -53,7 +49,7 @@ class Projects::RunnersController < Projects::ApplicationController def toggle_shared_runners project.toggle!(:shared_runners_enabled) - redirect_to namespace_project_runners_path(project.namespace, project) + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) end protected diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb new file mode 100644 index 00000000000..6f009d61950 --- /dev/null +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -0,0 +1,44 @@ +module Projects + module Settings + class CiCdController < Projects::ApplicationController + before_action :authorize_admin_pipeline! + + def show + define_runners_variables + define_secret_variables + define_triggers_variables + define_badges_variables + end + + private + + def define_runners_variables + @project_runners = @project.runners.ordered + @assignable_runners = current_user.ci_authorized_runners. + assignable_for(project).ordered.page(params[:page]).per(20) + @shared_runners = Ci::Runner.shared.active + @shared_runners_count = @shared_runners.count(:all) + end + + def define_secret_variables + @variable = Ci::Variable.new + end + + def define_triggers_variables + @triggers = @project.triggers + @trigger = Ci::Trigger.new + end + + def define_badges_variables + @ref = params[:ref] || @project.default_branch || 'master' + + @badges = [Gitlab::Badge::Build::Status, + Gitlab::Badge::Coverage::Report] + + @badges.map! do |badge| + badge.new(@project, @ref).metadata + end + end + end + end +end diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index 92359745cec..b2c11ea4156 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -4,8 +4,7 @@ class Projects::TriggersController < Projects::ApplicationController layout 'project_settings' def index - @triggers = project.triggers - @trigger = Ci::Trigger.new + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) end def create @@ -13,17 +12,18 @@ class Projects::TriggersController < Projects::ApplicationController @trigger.save if @trigger.valid? - redirect_to namespace_project_triggers_path(@project.namespace, @project) + redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Trigger was created successfully.' else @triggers = project.triggers.select(&:persisted?) - render :index + render action: "show" end end def destroy trigger.destroy + flash[:alert] = "Trigger removed" - redirect_to namespace_project_triggers_path(@project.namespace, @project) + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) end private diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 50ba33ed570..61686499bd3 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -1,6 +1,6 @@ class Projects::UploadsController < Projects::ApplicationController - skip_before_action :reject_blocked!, :project, - :repository, if: -> { action_name == 'show' && image_or_video? } + skip_before_action :project, :repository, + if: -> { action_name == 'show' && image_or_video? } before_action :authorize_upload_file!, only: [:create] diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 6f068729390..a4d1b1ee69b 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -4,7 +4,7 @@ class Projects::VariablesController < Projects::ApplicationController layout 'project_settings' def index - @variable = Ci::Variable.new + redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) end def show @@ -25,9 +25,10 @@ class Projects::VariablesController < Projects::ApplicationController @variable = Ci::Variable.new(project_params) if @variable.valid? && @project.variables << @variable - redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Variables were successfully updated.' + flash[:notice] = 'Variables were successfully updated.' + redirect_to namespace_project_settings_ci_cd_path(project.namespace, project) else - render action: "index" + render "show" end end @@ -35,7 +36,7 @@ class Projects::VariablesController < Projects::ApplicationController @key = @project.variables.find(params[:id]) @key.destroy - redirect_to namespace_project_variables_path(project.namespace, project), notice: 'Variable was successfully removed.' + redirect_to namespace_project_settings_ci_cd_path(project.namespace, project), notice: 'Variable was successfully removed.' end private diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index c3353446fd1..2d8064c9878 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -8,6 +8,7 @@ class Projects::WikisController < Projects::ApplicationController def pages @wiki_pages = Kaminari.paginate_array(@project_wiki.pages).page(params[:page]) + @wiki_entries = WikiPage.group_by_directory(@wiki_pages) end def show @@ -83,7 +84,7 @@ class Projects::WikisController < Projects::ApplicationController def destroy @page = @project_wiki.find_page(params[:id]) - @page.delete if @page + WikiPages::DestroyService.new(@project, current_user).execute(@page) redirect_to( namespace_project_wiki_path(@project.namespace, @project, :home), @@ -116,7 +117,7 @@ class Projects::WikisController < Projects::ApplicationController # Call #wiki to make sure the Wiki Repo is initialized @project_wiki.wiki - @sidebar_wiki_pages = @project_wiki.pages.first(15) + @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages.first(15)) rescue ProjectWiki::CouldNotCreateWikiError flash[:notice] = "Could not create Wiki Repository at this time. Please try again later." redirect_to project_path(@project) diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index bf27f3d4d51..b44f38d4a0c 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -17,20 +17,20 @@ class RegistrationsController < Devise::RegistrationsController if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha super else - flash[:alert] = 'There was an error with the reCAPTCHA. Please re-solve the reCAPTCHA.' + flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' flash.delete :recaptcha_error render action: 'new' end end def destroy - DeleteUserService.new(current_user).execute(current_user) + Users::DestroyService.new(current_user).execute(current_user) respond_to do |format| format.html do session.try(:destroy) redirect_to new_user_session_path, notice: "Account successfully removed." - end + end end end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 6576ebd5235..612d69cf557 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,5 +1,5 @@ class SearchController < ApplicationController - skip_before_action :authenticate_user!, :reject_blocked! + skip_before_action :authenticate_user! include SearchHelper diff --git a/app/finders/environments_finder.rb b/app/finders/environments_finder.rb new file mode 100644 index 00000000000..a59f8c1efa3 --- /dev/null +++ b/app/finders/environments_finder.rb @@ -0,0 +1,55 @@ +class EnvironmentsFinder + attr_reader :project, :current_user, :params + + def initialize(project, current_user, params = {}) + @project, @current_user, @params = project, current_user, params + end + + def execute + deployments = project.deployments + deployments = + if ref + deployments_query = params[:with_tags] ? 'ref = :ref OR tag IS TRUE' : 'ref = :ref' + deployments.where(deployments_query, ref: ref.to_s) + elsif commit + deployments.where(sha: commit.sha) + else + deployments.none + end + + environment_ids = deployments + .group(:environment_id) + .select(:environment_id) + + environments = project.environments.available + .where(id: environment_ids).order_by_last_deployed_at.to_a + + environments.select! do |environment| + Ability.allowed?(current_user, :read_environment, environment) + end + + if ref && commit + environments.select! do |environment| + environment.includes_commit?(commit) + end + end + + if ref && params[:recently_updated] + environments.select! do |environment| + environment.recently_updated_on_branch?(ref) + end + end + + environments + end + + private + + def ref + params[:ref].try(:to_s) + end + + def commit + params[:commit] + end +end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb new file mode 100644 index 00000000000..9f2206346ce --- /dev/null +++ b/app/finders/group_members_finder.rb @@ -0,0 +1,20 @@ +class GroupMembersFinder < Projects::ApplicationController + def initialize(group) + @group = group + end + + def execute + group_members = @group.members + + return group_members unless @group.parent + + parents_members = GroupMember.non_request. + where(source_id: @group.ancestors.select(:id)). + where.not(user_id: @group.users.select(:id)) + + wheres = ["members.id IN (#{group_members.select(:id).to_sql})"] + wheres << "members.id IN (#{parents_members.select(:id).to_sql})" + + GroupMember.where(wheres.join(' OR ')) + end +end diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index aa8f4c1d0e4..3b9a421b118 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -18,7 +18,7 @@ class GroupProjectsFinder < UnionFinder projects = [] if current_user - if @group.users.include?(current_user) || current_user.admin? + if @group.users.include?(current_user) projects << @group.projects unless only_shared projects << @group.shared_projects unless only_owned else diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index 4e43f42e9e1..d932a17883f 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -2,7 +2,7 @@ class GroupsFinder < UnionFinder def execute(current_user = nil) segments = all_groups(current_user) - find_union(segments, Group).order_id_desc + find_union(segments, Group).with_route.order_id_desc end private diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index c7911736812..18ec45f300d 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -3,7 +3,7 @@ class ProjectsFinder < UnionFinder segments = all_projects(current_user) segments.map! { |s| s.where(id: project_ids_relation) } if project_ids_relation - find_union(segments, Project) + find_union(segments, Project).with_route end private diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 9fc69e12266..ff937b5ebd2 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,7 +1,7 @@ module BuildsHelper def sidebar_build_class(build, current_build) build_class = '' - build_class += ' active' if build == current_build + build_class += ' active' if build.id === current_build.id build_class += ' retried' if build.retried? build_class end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 6dcb624c4da..8aad39e148b 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -194,7 +194,7 @@ module CommitsHelper end end - def view_file_btn(commit_sha, diff_new_path, project) + def view_file_button(commit_sha, diff_new_path, project) link_to( namespace_project_blob_path(project.namespace, project, tree_join(commit_sha, diff_new_path)), @@ -205,6 +205,17 @@ module CommitsHelper end end + def view_on_environment_button(commit_sha, diff_new_path, environment) + return unless environment && commit_sha + + external_url = environment.external_url_for(diff_new_path, commit_sha) + return unless external_url + + link_to(external_url, class: 'btn btn-file-option has-tooltip', target: '_blank', title: "View on #{environment.formatted_external_url}", data: { container: 'body' }) do + icon('external-link') + end + end + def truncate_sha(sha) Commit.truncate_sha(sha) end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 2159e4ce21a..f16a63e2178 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -211,8 +211,12 @@ module GitlabRoutingHelper def project_settings_integrations_path(project, *args) namespace_project_settings_integrations_path(project.namespace, project, *args) end - + def project_settings_members_path(project, *args) namespace_project_settings_members_path(project.namespace, project, *args) end + + def project_settings_ci_cd_path(project, *args) + namespace_project_settings_ci_cd_path(project.namespace, project, *args) + end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 83ff898e68a..b5f8c23a667 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -20,8 +20,8 @@ module MergeRequestsHelper end def mr_widget_refresh_url(mr) - if mr && mr.source_project - merge_widget_refresh_namespace_project_merge_request_url(mr.source_project.namespace, mr.source_project, mr) + if mr && mr.target_project + merge_widget_refresh_namespace_project_merge_request_url(mr.target_project.namespace, mr.target_project, mr) else '' end @@ -64,11 +64,11 @@ module MergeRequestsHelper end def mr_closes_issues - @mr_closes_issues ||= @merge_request.closes_issues + @mr_closes_issues ||= @merge_request.closes_issues(current_user) end def mr_issues_mentioned_but_not_closing - @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing + @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing(current_user) end def mr_change_branches_path(merge_request) diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 6e68aad4cb7..dd0a4ea03f0 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -23,7 +23,7 @@ module PreferencesHelper if defined.size != DASHBOARD_CHOICES.size # Ensure that anyone adding new options updates this method too - raise RuntimeError, "`User` defines #{defined.size} dashboard choices," + + raise "`User` defines #{defined.size} dashboard choices," \ " but `DASHBOARD_CHOICES` defined #{DASHBOARD_CHOICES.size}." else defined.map do |key, _| diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index c568cca9e5e..845f1a0e840 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -15,6 +15,7 @@ module TodosHelper when Todo::MARKED then 'added a todo for' when Todo::APPROVAL_REQUIRED then 'set you as an approver for' when Todo::UNMERGEABLE then 'Could not merge' + when Todo::DIRECTLY_ADDRESSED then 'directly addressed you on' end end @@ -86,7 +87,10 @@ module TodosHelper [ { id: '', text: 'Any Action' }, { id: Todo::ASSIGNED, text: 'Assigned' }, - { id: Todo::MENTIONED, text: 'Mentioned' } + { id: Todo::MENTIONED, text: 'Mentioned' }, + { id: Todo::MARKED, text: 'Added' }, + { id: Todo::BUILD_FAILED, text: 'Pipelines' }, + { id: Todo::DIRECTLY_ADDRESSED, text: 'Directly addressed' } ] end diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb new file mode 100644 index 00000000000..3e3f6246fc5 --- /dev/null +++ b/app/helpers/wiki_helper.rb @@ -0,0 +1,13 @@ +module WikiHelper + # Produces a pure text breadcrumb for a given page. + # + # page_slug - The slug of a WikiPage object. + # + # Returns a String composed of the capitalized name of each directory and the + # capitalized name of the page itself. + def breadcrumb(page_slug) + page_slug.split('/'). + map { |dir_or_page| WikiPage.unhyphenize(dir_or_page).capitalize }. + join(' / ') + end +end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 0cd3456b4de..5b9226a6b81 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -151,7 +151,7 @@ class Notify < BaseMailer headers['In-Reply-To'] = message_id(model) headers['References'] = message_id(model) - headers[:subject].prepend('Re: ') if headers[:subject] + headers[:subject]&.prepend('Re: ') mail_thread(model, headers) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 9a4557524c4..74b358d8c40 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -116,31 +116,25 @@ class ApplicationSetting < ActiveRecord::Base numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates_each :restricted_visibility_levels do |record, attr, value| - unless value.nil? - value.each do |level| - unless Gitlab::VisibilityLevel.options.has_value?(level) - record.errors.add(attr, "'#{level}' is not a valid visibility level") - end + value&.each do |level| + unless Gitlab::VisibilityLevel.options.has_value?(level) + record.errors.add(attr, "'#{level}' is not a valid visibility level") end end end validates_each :import_sources do |record, attr, value| - unless value.nil? - value.each do |source| - unless Gitlab::ImportSources.options.has_value?(source) - record.errors.add(attr, "'#{source}' is not a import source") - end + value&.each do |source| + unless Gitlab::ImportSources.options.has_value?(source) + record.errors.add(attr, "'#{source}' is not a import source") end end end validates_each :disabled_oauth_sign_in_sources do |record, attr, value| - unless value.nil? - value.each do |source| - unless Devise.omniauth_providers.include?(source.to_sym) - record.errors.add(attr, "'#{source}' is not an OAuth sign-in source") - end + value&.each do |source| + unless Devise.omniauth_providers.include?(source.to_sym) + record.errors.add(attr, "'#{source}' is not an OAuth sign-in source") end end end @@ -230,11 +224,11 @@ class ApplicationSetting < ActiveRecord::Base end def domain_whitelist_raw - self.domain_whitelist.join("\n") unless self.domain_whitelist.nil? + self.domain_whitelist&.join("\n") end def domain_blacklist_raw - self.domain_blacklist.join("\n") unless self.domain_blacklist.nil? + self.domain_blacklist&.join("\n") end def domain_whitelist_raw=(values) diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 46b17479d6d..6937ad3bdd9 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -16,6 +16,14 @@ class AwardEmoji < ActiveRecord::Base scope :downvotes, -> { where(name: DOWNVOTE_NAME) } scope :upvotes, -> { where(name: UPVOTE_NAME) } + class << self + def votes_for_collection(ids, type) + select('name', 'awardable_id', 'COUNT(*) as count'). + where('name IN (?) AND awardable_type = ? AND awardable_id IN (?)', [DOWNVOTE_NAME, UPVOTE_NAME], type, ids). + group('name', 'awardable_id') + end + end + def downvote? self.name == DOWNVOTE_NAME end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 44d4fb9d8d8..8c1b076c2d7 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -9,6 +9,7 @@ module Ci belongs_to :erased_by, class_name: 'User' has_many :deployments, as: :deployable + has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment' # The "environment" field for builds is a String, and is the unexpanded name def persisted_environment @@ -19,7 +20,7 @@ module Ci end serialize :options - serialize :yaml_variables, Gitlab::Serialize::Ci::Variables + serialize :yaml_variables, Gitlab::Serializer::Ci::Variables validates :coverage, numericality: true, allow_blank: true validates_presence_of :ref @@ -41,7 +42,7 @@ module Ci before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token - before_destroy { project } + before_destroy { unscoped_project } after_create :execute_hooks after_save :update_project_statistics, if: :artifacts_size_changed? @@ -183,10 +184,6 @@ module Ci success? && !last_deployment.try(:last?) end - def last_deployment - deployments.last - end - def depends_on_builds # Get builds of the same type latest_builds = self.pipeline.builds.latest @@ -416,16 +413,23 @@ module Ci # This method returns old path to artifacts only if it already exists. # def artifacts_path + # We need the project even if it's soft deleted, because whenever + # we're really deleting the project, we'll also delete the builds, + # and in order to delete the builds, we need to know where to find + # the artifacts, which is depending on the data of the project. + # We need to retain the project in this case. + the_project = project || unscoped_project + old = File.join(created_at.utc.strftime('%Y_%m'), - project.ci_id.to_s, + the_project.ci_id.to_s, id.to_s) old_store = File.join(ArtifactUploader.artifacts_path, old) - return old if project.ci_id && File.directory?(old_store) + return old if the_project.ci_id && File.directory?(old_store) File.join( created_at.utc.strftime('%Y_%m'), - project.id.to_s, + the_project.id.to_s, id.to_s ) end @@ -560,6 +564,10 @@ module Ci self.update(erased_by: user, erased_at: Time.now, artifacts_expire_at: nil) end + def unscoped_project + @unscoped_project ||= Project.unscoped.find_by(id: gl_project_id) + end + def predefined_variables variables = [ { key: 'CI', value: 'true', public: true }, @@ -598,6 +606,8 @@ module Ci end def update_project_statistics + return unless project + ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size]) end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index fab8497ec7d..bbc358adb83 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -283,13 +283,7 @@ module Ci def ci_yaml_file return @ci_yaml_file if defined?(@ci_yaml_file) - @ci_yaml_file ||= begin - blob = project.repository.blob_at(sha, '.gitlab-ci.yml') - blob.load_all_data!(project.repository) - blob.data - rescue - nil - end + @ci_yaml_file = project.repository.gitlab_ci_yml_for(sha) rescue nil end def has_yaml_errors? diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3517969eabc..5f53c48fc88 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -15,6 +15,11 @@ module Issuable include Taskable include TimeTrackable + # This object is used to gather issuable meta data for displaying + # upvotes, downvotes and notes count for issues and merge requests + # lists avoiding n+1 queries and improving performance. + IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count) + included do cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -95,8 +100,8 @@ module Issuable def update_assignee_cache_counts # make sure we flush the cache for both the old *and* new assignees(if they exist) previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was - previous_assignee.update_cache_counts if previous_assignee - assignee.update_cache_counts if assignee + previous_assignee&.update_cache_counts + assignee&.update_cache_counts end # We want to use optimistic lock for cases when only title or description are involved diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index ef2c1e5d414..7e56e371b27 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -44,8 +44,15 @@ module Mentionable end def all_references(current_user = nil, extractor: nil) - extractor ||= Gitlab::ReferenceExtractor. - new(project, current_user) + # Use custom extractor if it's passed in the function parameters. + if extractor + @extractor = extractor + else + @extractor ||= Gitlab::ReferenceExtractor. + new(project, current_user) + + @extractor.reset_memoized_values + end self.class.mentionable_attrs.each do |attr, options| text = __send__(attr) @@ -55,16 +62,20 @@ module Mentionable skip_project_check: skip_project_check? ) - extractor.analyze(text, options) + @extractor.analyze(text, options) end - extractor + @extractor end def mentioned_users(current_user = nil) all_references(current_user).users end + def directly_addressed_users(current_user = nil) + all_references(current_user).directly_addressed_users + end + # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def referenced_mentionables(current_user = self.author) refs = all_references(current_user) diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index e9450dd0c26..f449229864d 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -73,7 +73,7 @@ module Milestoneish def memoize_per_user(user, method_name) @memoized ||= {} @memoized[method_name] ||= {} - @memoized[method_name][user.try!(:id)] ||= yield + @memoized[method_name][user&.id] ||= yield end # override in a class that includes this module to get a faster query diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 2b93aa30c0f..9f6d215ceb3 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -1,5 +1,5 @@ # Store object full path in separate table for easy lookup and uniq validation -# Object must have path db field and respond to full_path and full_path_changed? methods. +# Object must have name and path db fields and respond to parent and parent_changed? methods. module Routable extend ActiveSupport::Concern @@ -9,7 +9,13 @@ module Routable validates_associated :route validates :route, presence: true - before_validation :update_route_path, if: :full_path_changed? + scope :with_route, -> { includes(:route) } + + before_validation do + if full_path_changed? || full_name_changed? + prepare_route + end + end end class_methods do @@ -77,10 +83,62 @@ module Routable end end + def full_name + if route && route.name.present? + @full_name ||= route.name + else + update_route if persisted? + + build_full_name + end + end + + def full_path + if route && route.path.present? + @full_path ||= route.path + else + update_route if persisted? + + build_full_path + end + end + private - def update_route_path + def full_name_changed? + name_changed? || parent_changed? + end + + def full_path_changed? + path_changed? || parent_changed? + end + + def build_full_name + if parent && name + parent.human_name + ' / ' + name + else + name + end + end + + def build_full_path + if parent && path + parent.full_path + '/' + path + else + path + end + end + + def update_route + prepare_route + route.save + end + + def prepare_route route || build_route(source: self) - route.path = full_path + route.path = build_full_path + route.name = build_full_name + @full_path = nil + @full_name = nil end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 1acff093aa1..423ae98a60e 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -11,6 +11,7 @@ module Spammable has_one :user_agent_detail, as: :subject, dependent: :destroy attr_accessor :spam + attr_accessor :spam_log after_validation :check_for_spam, on: :create @@ -34,9 +35,14 @@ module Spammable end def check_for_spam - if spam? - self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.") - end + error_msg = if Gitlab::Recaptcha.enabled? + "Your #{spammable_entity_type} has been recognized as spam. "\ + "You can still submit it by solving Captcha." + else + "Your #{spammable_entity_type} has been recognized as spam and has been discarded." + end + + self.errors.add(:base, error_msg) if spam? end def spammable_entity_type diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 040e3a2884e..9cf83440784 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -18,7 +18,7 @@ module TimeTrackable validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false validate :check_negative_time_spent - has_many :timelogs, as: :trackable, dependent: :destroy + has_many :timelogs, dependent: :destroy end def spend_time(options) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 91d85c2279b..afad001d50f 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -91,7 +91,7 @@ class Deployment < ActiveRecord::Base @stop_action ||= manual_actions.find_by(name: on_stop) end - def stoppable? + def stop_action? stop_action.present? end diff --git a/app/models/directly_addressed_user.rb b/app/models/directly_addressed_user.rb new file mode 100644 index 00000000000..0d519c6ac22 --- /dev/null +++ b/app/models/directly_addressed_user.rb @@ -0,0 +1,7 @@ +class DirectlyAddressedUser + class << self + def reference_pattern + User.reference_pattern + end + end +end diff --git a/app/models/environment.rb b/app/models/environment.rb index 577367f1eed..1a21b5e52b5 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -6,7 +6,8 @@ class Environment < ActiveRecord::Base belongs_to :project, required: true, validate: true - has_many :deployments + has_many :deployments, dependent: :destroy + has_one :last_deployment, -> { order('deployments.id DESC') }, class_name: 'Deployment' before_validation :nullify_external_url before_validation :generate_slug, if: ->(env) { env.slug.blank? } @@ -37,6 +38,13 @@ class Environment < ActiveRecord::Base scope :available, -> { with_state(:available) } scope :stopped, -> { with_state(:stopped) } + scope :order_by_last_deployed_at, -> do + max_deployment_id_sql = + Deployment.select(Deployment.arel_table[:id].maximum). + where(Deployment.arel_table[:environment_id].eq(arel_table[:id])). + to_sql + order(Gitlab::Database.nulls_first_order("(#{max_deployment_id_sql})", 'ASC')) + end state_machine :state, initial: :available do event :start do @@ -62,10 +70,6 @@ class Environment < ActiveRecord::Base ref.to_s == last_deployment.try(:ref) end - def last_deployment - deployments.last - end - def nullify_external_url self.external_url = nil if self.external_url.blank? end @@ -87,6 +91,10 @@ class Environment < ActiveRecord::Base last_deployment.includes_commit?(commit) end + def last_deployed_at + last_deployment.try(:created_at) + end + def update_merge_request_metrics? (environment_type || name) == "production" end @@ -110,15 +118,15 @@ class Environment < ActiveRecord::Base external_url.gsub(/\A.*?:\/\//, '') end - def stoppable? + def stop_action? available? && stop_action.present? end - def stop!(current_user) - return unless stoppable? + def stop_with_action!(current_user) + return unless available? - stop - stop_action.play(current_user) + stop! + stop_action&.play(current_user) end def actions_for(environment) @@ -171,6 +179,15 @@ class Environment < ActiveRecord::Base self.slug = slugified end + def external_url_for(path, commit_sha) + return unless self.external_url + + public_path = project.public_path_for_source_path(path, commit_sha) + return unless public_path + + [external_url, public_path].join('/') + end + private # Slugifying a name may remove the uniqueness guarantee afforded by it being diff --git a/app/models/event.rb b/app/models/event.rb index 2662f170765..e5027df3f8a 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -36,18 +36,19 @@ class Event < ActiveRecord::Base scope :code_push, -> { where(action: PUSHED) } scope :in_projects, ->(projects) do - where(project_id: projects.map(&:id)).recent + where(project_id: projects).recent end - scope :with_associations, -> { includes(project: :namespace) } + scope :with_associations, -> { includes(:author, :project, project: :namespace).preload(:target) } scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) } class << self # Update Gitlab::ContributionsCalendar#activity_dates if this changes def contributions - where("action = ? OR (target_type in (?) AND action in (?))", - Event::PUSHED, ["MergeRequest", "Issue"], - [Event::CREATED, Event::CLOSED, Event::MERGED]) + where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)", + Event::PUSHED, + ["MergeRequest", "Issue"], [Event::CREATED, Event::CLOSED, Event::MERGED], + "Note", Event::COMMENTED) end def limit_recent(limit = 20, offset = nil) diff --git a/app/models/group.rb b/app/models/group.rb index 4cdfd022094..240a17f1dc1 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -81,7 +81,7 @@ class Group < Namespace end def to_reference(_from_project = nil, full: nil) - "#{self.class.reference_prefix}#{name}" + "#{self.class.reference_prefix}#{full_path}" end def web_url @@ -197,11 +197,16 @@ class Group < Namespace end def refresh_members_authorized_projects - UserProjectAccessChangedService.new(users_with_parents.pluck(:id)).execute + UserProjectAccessChangedService.new(user_ids_for_project_authorizations). + execute + end + + def user_ids_for_project_authorizations + users_with_parents.pluck(:id) end def members_with_parents - GroupMember.where(requested_at: nil, source_id: ancestors.map(&:id).push(id)) + GroupMember.non_request.where(source_id: ancestors.map(&:id).push(id)) end def users_with_parents diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index 7b6db2634b7..86d38e5468b 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -9,7 +9,7 @@ class GroupMilestone < GlobalMilestone def self.build(group, projects, title) super(projects, title).tap do |milestone| - milestone.group = group if milestone + milestone&.group = group end end diff --git a/app/models/member.rb b/app/models/member.rb index 26a6054e00d..d07f270b757 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -47,6 +47,7 @@ class Member < ActiveRecord::Base scope :invite, -> { where.not(invite_token: nil) } scope :non_invite, -> { where(invite_token: nil) } scope :request, -> { where.not(requested_at: nil) } + scope :non_request, -> { where(requested_at: nil) } scope :has_access, -> { active.where('access_level > 0') } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 082adcafcc8..38646eba3ac 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -546,7 +546,7 @@ class MergeRequest < ActiveRecord::Base # Calculating this information for a number of merge requests requires # running `ReferenceExtractor` on each of them separately. # This optimization does not apply to issues from external sources. - def cache_merge_request_closes_issues!(current_user = self.author) + def cache_merge_request_closes_issues!(current_user) return if project.has_external_issue_tracker? transaction do @@ -558,14 +558,10 @@ class MergeRequest < ActiveRecord::Base end end - def closes_issue?(issue) - closes_issues.include?(issue) - end - # Return the set of issues that will be closed if this merge request is accepted. def closes_issues(current_user = self.author) if target_branch == project.default_branch - messages = [description] + messages = [title, description] messages.concat(commits.map(&:safe_message)) if merge_request_diff Gitlab::ClosingIssueExtractor.new(project, current_user). @@ -575,13 +571,13 @@ class MergeRequest < ActiveRecord::Base end end - def issues_mentioned_but_not_closing(current_user = self.author) + def issues_mentioned_but_not_closing(current_user) return [] unless target_branch == project.default_branch ext = Gitlab::ReferenceExtractor.new(project, current_user) - ext.analyze(description) + ext.analyze("#{title}\n#{description}") - ext.issues - closes_issues + ext.issues - closes_issues(current_user) end def target_project_path @@ -715,18 +711,22 @@ class MergeRequest < ActiveRecord::Base !head_pipeline || head_pipeline.success? || head_pipeline.skipped? end - def environments + def environments_for(current_user) return [] unless diff_head_commit - @environments ||= begin - target_envs = target_project.environments_for( - target_branch, commit: diff_head_commit, with_tags: true) + @environments ||= Hash.new do |h, current_user| + envs = EnvironmentsFinder.new(target_project, current_user, + ref: target_branch, commit: diff_head_commit, with_tags: true).execute - source_envs = source_project.environments_for( - source_branch, commit: diff_head_commit) if source_project + if source_project + envs.concat EnvironmentsFinder.new(source_project, current_user, + ref: source_branch, commit: diff_head_commit).execute + end - (target_envs.to_a + source_envs.to_a).uniq + h[current_user] = envs.uniq end + + @environments[current_user] end def state_human_name diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 2fb2eb44aaa..6de4d08fc28 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -7,6 +7,11 @@ class Namespace < ActiveRecord::Base include Gitlab::CurrentSettings include Routable + # Prevent users from creating unreasonably deep level of nesting. + # The number 20 was taken based on maximum nesting level of + # Android repo (15) + some extra backup. + NUMBER_OF_ANCESTORS_ALLOWED = 20 + cache_markdown_field :description, pipeline: :description has_many :projects, dependent: :destroy @@ -29,6 +34,8 @@ class Namespace < ActiveRecord::Base length: { maximum: 255 }, namespace: true + validate :nesting_level_allowed + delegate :name, to: :owner, allow_nil: true, prefix: true after_update :move_dir, if: :path_changed? @@ -170,31 +177,14 @@ class Namespace < ActiveRecord::Base Gitlab.config.lfs.enabled end - def full_path - if parent - parent.full_path + '/' + path - else - path - end - end - def shared_runners_enabled? projects.with_shared_runners.any? end - def full_name - @full_name ||= - if parent - parent.full_name + ' / ' + name - else - name - end - end - # Scopes the model on ancestors of the record def ancestors if parent_id - path = route.path + path = route ? route.path : full_path paths = [] until path.blank? @@ -213,6 +203,14 @@ class Namespace < ActiveRecord::Base self.class.joins(:route).where('routes.path LIKE ?', "#{route.path}/%").reorder('routes.path ASC') end + def user_ids_for_project_authorizations + [owner_id] + end + + def parent_changed? + parent_id_changed? + end + private def repository_storage_paths @@ -251,10 +249,6 @@ class Namespace < ActiveRecord::Base find_each(&:refresh_members_authorized_projects) end - def full_path_changed? - path_changed? || parent_id_changed? - end - def remove_exports! Gitlab::Popen.popen(%W(find #{export_path} -not -path #{export_path} -delete)) end @@ -270,4 +264,10 @@ class Namespace < ActiveRecord::Base path_was end end + + def nesting_level_allowed + if ancestors.count > Group::NUMBER_OF_ANCESTORS_ALLOWED + errors.add(:parent_id, "has too deep level of nesting") + end + end end diff --git a/app/models/note.rb b/app/models/note.rb index bf090a0438c..029fe667a45 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -108,6 +108,12 @@ class Note < ActiveRecord::Base Discussion.for_diff_notes(active_notes). map { |d| [d.line_code, d] }.to_h end + + def count_for_collection(ids, type) + user.select('noteable_id', 'COUNT(*) as count'). + group(:noteable_id). + where(noteable_type: type, noteable_id: ids) + end end def cross_reference? diff --git a/app/models/project.rb b/app/models/project.rb index 7c5fdad5122..aa408b4556e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -228,7 +228,12 @@ class Project < ActiveRecord::Base scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } scope :with_statistics, -> { includes(:statistics) } scope :with_shared_runners, -> { where(shared_runners_enabled: true) } - scope :inside_path, ->(path) { joins(:route).where('routes.path LIKE ?', "#{path}/%") } + scope :inside_path, ->(path) do + # We need routes alias rs for JOIN so it does not conflict with + # includes(:route) which we use in ProjectsFinder. + joins("INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'"). + where('rs.path LIKE ?', "#{path}/%") + end # "enabled" here means "not disabled". It includes private features! scope :with_feature_enabled, ->(feature) { @@ -464,7 +469,7 @@ class Project < ActiveRecord::Base def reset_cache_and_import_attrs ProjectCacheWorker.perform_async(self.id) - self.import_data.destroy if self.import_data + self.import_data&.destroy end def import_url=(value) @@ -810,26 +815,6 @@ class Project < ActiveRecord::Base end end - def name_with_namespace - @name_with_namespace ||= begin - if namespace - namespace.human_name + ' / ' + name - else - name - end - end - end - alias_method :human_name, :name_with_namespace - - def full_path - if namespace && path - namespace.full_path + '/' + path - else - path - end - end - alias_method :path_with_namespace, :full_path - def execute_hooks(data, hooks_scope = :push_hooks) hooks.send(hooks_scope).each do |hook| hook.async_execute(data, hooks_scope.to_s) @@ -1306,30 +1291,40 @@ class Project < ActiveRecord::Base Gitlab::Redis.with { |redis| redis.del(pushes_since_gc_redis_key) } end - def environments_for(ref, commit: nil, with_tags: false) - deployments_query = with_tags ? 'ref = ? OR tag IS TRUE' : 'ref = ?' + def route_map_for(commit_sha) + @route_maps_by_commit ||= Hash.new do |h, sha| + h[sha] = begin + data = repository.route_map_for(sha) + next unless data + + Gitlab::RouteMap.new(data) + rescue Gitlab::RouteMap::FormatError + nil + end + end - environment_ids = deployments - .where(deployments_query, ref.to_s) - .group(:environment_id) - .select(:environment_id) + @route_maps_by_commit[commit_sha] + end - environments_found = environments.available - .where(id: environment_ids).to_a + def public_path_for_source_path(path, commit_sha) + map = route_map_for(commit_sha) + return unless map - return environments_found unless commit + map.public_path_for_source_path(path) + end - environments_found.select do |environment| - environment.includes_commit?(commit) - end + def parent + namespace end - def environments_recently_updated_on_branch(branch) - environments_for(branch).select do |environment| - environment.recently_updated_on_branch?(branch) - end + def parent_changed? + namespace_id_changed? end + alias_method :name_with_namespace, :full_name + alias_method :human_name, :full_name + alias_method :path_with_namespace, :full_path + private def cross_namespace_reference?(from) @@ -1368,10 +1363,6 @@ class Project < ActiveRecord::Base raise BoardLimitExceeded, 'Number of permitted boards exceeded' if boards.size >= NUMBER_OF_PERMITTED_BOARDS end - def full_path_changed? - path_changed? || namespace_id_changed? - end - def update_project_statistics stats = statistics || build_statistics stats.update(namespace_id: namespace_id) diff --git a/app/models/project_services/chat_slash_commands_service.rb b/app/models/project_services/chat_slash_commands_service.rb index 5eb1bd86e9d..8b5bc24fd3c 100644 --- a/app/models/project_services/chat_slash_commands_service.rb +++ b/app/models/project_services/chat_slash_commands_service.rb @@ -23,7 +23,7 @@ class ChatSlashCommandsService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' } + { type: 'text', name: 'token', placeholder: 'XXxxXXxxXXxxXXxxXXxxXXxx' } ] end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 80d002f9c32..eef403dba92 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -250,21 +250,11 @@ class JiraService < IssueTrackerService end end - # Build remote link on JIRA properties - # Icons here must be available on WEB so JIRA can read the URL - # We are using a open word graphics icon which have LGPL license def build_remote_link_props(url:, title:, resolved: false) status = { resolved: resolved } - if resolved - status[:icon] = { - title: 'Closed', - url16x16: 'http://www.openwebgraphics.com/resources/data/1768/16x16_apply.png' - } - end - { GlobalID: 'GitLab', object: { diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb index b0f7a42f9a3..56f42d63b2d 100644 --- a/app/models/project_services/mattermost_slash_commands_service.rb +++ b/app/models/project_services/mattermost_slash_commands_service.rb @@ -8,11 +8,11 @@ class MattermostSlashCommandsService < ChatSlashCommandsService end def title - 'Mattermost Command' + 'Mattermost slash commands' end def description - "Perform common operations on GitLab in Mattermost" + "Perform common operations in Mattermost" end def self.to_param diff --git a/app/models/project_services/slack_slash_commands_service.rb b/app/models/project_services/slack_slash_commands_service.rb index c34991e4262..2182c1c7e4b 100644 --- a/app/models/project_services/slack_slash_commands_service.rb +++ b/app/models/project_services/slack_slash_commands_service.rb @@ -2,11 +2,11 @@ class SlackSlashCommandsService < ChatSlashCommandsService include TriggersHelper def title - 'Slack Command' + 'Slack slash commands' end def description - "Perform common operations on GitLab in Slack" + "Perform common operations in Slack" end def self.to_param diff --git a/app/models/repository.rb b/app/models/repository.rb index 7cf09c52bf4..56c582cd9be 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -464,6 +464,8 @@ class Repository unless Gitlab::Git.blank_ref?(sha) Blob.decorate(Gitlab::Git::Blob.find(self, sha, path)) end + rescue Gitlab::Git::Repository::NoRepository + nil end def blob_by_oid(oid) @@ -1160,6 +1162,14 @@ class Repository end end + def route_map_for(sha) + blob_data_at(sha, '.gitlab/route-map.yml') + end + + def gitlab_ci_yml_for(sha) + blob_data_at(sha, '.gitlab-ci.yml') + end + protected def tree_entry_at(branch_name, path) @@ -1186,6 +1196,14 @@ class Repository private + def blob_data_at(sha, path) + blob = blob_at(sha, path) + return unless blob + + blob.load_all_data!(self) + blob.data + end + def git_action(index, action) path = normalize_path(action[:file_path]) @@ -1212,6 +1230,14 @@ class Repository action[:content] end + detect = CharlockHolmes::EncodingDetector.new.detect(content) if content + + unless detect && detect[:type] == :binary + # When writing to the repo directly as we are doing here, + # the `core.autocrlf` config isn't taken into account. + content.gsub!("\r\n", "\n") if self.autocrlf + end + oid = rugged.write(content, :blob) index.add(path: path, oid: oid, mode: mode) diff --git a/app/models/route.rb b/app/models/route.rb index dd171fdb069..73574a6206b 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -8,16 +8,27 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } - after_update :rename_descendants, if: :path_changed? + after_update :rename_descendants def rename_descendants - # We update each row separately because MySQL does not have regexp_replace. - # rubocop:disable Rails/FindEach - Route.where('path LIKE ?', "#{path_was}/%").each do |route| - # Note that update column skips validation and callbacks. - # We need this to avoid recursive call of rename_descendants method - route.update_column(:path, route.path.sub(path_was, path)) + if path_changed? || name_changed? + descendants = Route.where('path LIKE ?', "#{path_was}/%") + + descendants.each do |route| + attributes = {} + + if path_changed? && route.path.present? + attributes[:path] = route.path.sub(path_was, path) + end + + if name_changed? && route.name.present? + attributes[:name] = route.name.sub(name_was, name) + end + + # Note that update_columns skips validation and callbacks. + # We need this to avoid recursive call of rename_descendants method + route.update_columns(attributes) unless attributes.empty? + end end - # rubocop:enable Rails/FindEach end end diff --git a/app/models/timelog.rb b/app/models/timelog.rb index f768c4e3da5..e166cf69703 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -1,6 +1,22 @@ class Timelog < ActiveRecord::Base validates :time_spent, :user, presence: true + validate :issuable_id_is_present - belongs_to :trackable, polymorphic: true + belongs_to :issue + belongs_to :merge_request belongs_to :user + + def issuable + issue || merge_request + end + + private + + def issuable_id_is_present + if issue_id && merge_request_id + errors.add(:base, 'Only Issue ID or Merge Request ID is required') + elsif issuable.nil? + errors.add(:base, 'Issue or Merge Request ID is required') + end + end end diff --git a/app/models/todo.rb b/app/models/todo.rb index 2adf494ce11..3dda7948d0b 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -1,12 +1,13 @@ class Todo < ActiveRecord::Base include Sortable - ASSIGNED = 1 - MENTIONED = 2 - BUILD_FAILED = 3 - MARKED = 4 - APPROVAL_REQUIRED = 5 # This is an EE-only feature - UNMERGEABLE = 6 + ASSIGNED = 1 + MENTIONED = 2 + BUILD_FAILED = 3 + MARKED = 4 + APPROVAL_REQUIRED = 5 # This is an EE-only feature + UNMERGEABLE = 6 + DIRECTLY_ADDRESSED = 7 ACTION_NAMES = { ASSIGNED => :assigned, @@ -14,7 +15,8 @@ class Todo < ActiveRecord::Base BUILD_FAILED => :build_failed, MARKED => :marked, APPROVAL_REQUIRED => :approval_required, - UNMERGEABLE => :unmergeable + UNMERGEABLE => :unmergeable, + DIRECTLY_ADDRESSED => :directly_addressed } belongs_to :author, class_name: "User" diff --git a/app/models/user.rb b/app/models/user.rb index 54f5388eb2c..ad997ce2b13 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -51,7 +51,12 @@ class User < ActiveRecord::Base has_one :namespace, -> { where type: nil }, dependent: :destroy, foreign_key: :owner_id # Profile - has_many :keys, dependent: :destroy + has_many :keys, -> do + type = Key.arel_table[:type] + where(type.not_eq('DeployKey').or(type.eq(nil))) + end, dependent: :destroy + has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :destroy + has_many :emails, dependent: :destroy has_many :personal_access_tokens, dependent: :destroy has_many :identities, dependent: :destroy, autosave: true @@ -83,8 +88,6 @@ class User < ActiveRecord::Base has_many :events, dependent: :destroy, foreign_key: :author_id has_many :subscriptions, dependent: :destroy has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" - has_many :assigned_issues, dependent: :destroy, foreign_key: :assignee_id, class_name: "Issue" - has_many :assigned_merge_requests, dependent: :destroy, foreign_key: :assignee_id, class_name: "MergeRequest" has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy has_one :abuse_report, dependent: :destroy has_many :spam_logs, dependent: :destroy @@ -94,6 +97,9 @@ class User < ActiveRecord::Base has_many :notification_settings, dependent: :destroy has_many :award_emoji, dependent: :destroy + has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue" + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" + # # Validations # @@ -118,7 +124,7 @@ class User < ActiveRecord::Base validates :avatar, file_size: { maximum: 200.kilobytes.to_i } before_validation :generate_password, on: :create - before_validation :signup_domain_valid?, on: :create + before_validation :signup_domain_valid?, on: :create, if: ->(user) { !user.created_by_id } before_validation :sanitize_attrs before_validation :set_notification_email, if: ->(user) { user.email_changed? } before_validation :set_public_email, if: ->(user) { user.public_email_changed? } @@ -166,6 +172,15 @@ class User < ActiveRecord::Base def blocked? true end + + def active_for_authentication? + false + end + + def inactive_message + "Your account has been blocked. Please contact your GitLab " \ + "administrator if you think this is an error." + end end end @@ -304,7 +319,7 @@ class User < ActiveRecord::Base def find_by_personal_access_token(token_string) personal_access_token = PersonalAccessToken.active.find_by_token(token_string) if token_string - personal_access_token.user if personal_access_token + personal_access_token&.user end # Returns a user for the given SSH key. @@ -320,7 +335,7 @@ class User < ActiveRecord::Base def reference_pattern %r{ #{Regexp.escape(reference_prefix)} - (?<user>#{Gitlab::Regex::NAMESPACE_REGEX_STR}) + (?<user>#{Gitlab::Regex::NAMESPACE_REF_REGEX_STR}) }x end end @@ -903,6 +918,21 @@ class User < ActiveRecord::Base end end + def access_level + if admin? + :admin + else + :regular + end + end + + def access_level=(new_level) + new_level = new_level.to_s + return unless %w(admin regular).include?(new_level) + + self.admin = (new_level == 'admin') + end + private def ci_projects_union diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb new file mode 100644 index 00000000000..9340fc2dbbe --- /dev/null +++ b/app/models/wiki_directory.rb @@ -0,0 +1,18 @@ +class WikiDirectory + include ActiveModel::Validations + + attr_accessor :slug, :pages + + validates :slug, presence: true + + def initialize(slug, pages = []) + @slug = slug + @pages = pages + end + + # Relative path to the partial to be used when rendering collections + # of this object. + def to_partial_path + 'projects/wikis/wiki_directory' + end +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index c3de278f5b7..2caebb496db 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -12,6 +12,32 @@ class WikiPage ActiveModel::Name.new(self, nil, 'wiki') end + # Sorts and groups pages by directory. + # + # pages - an array of WikiPage objects. + # + # Returns an array of WikiPage and WikiDirectory objects. The entries are + # sorted by alphabetical order (directories and pages inside each directory). + # Pages at the root level come before everything. + def self.group_by_directory(pages) + return [] if pages.blank? + + pages.sort_by { |page| [page.directory, page.slug] }. + group_by(&:directory). + map do |dir, pages| + if dir.present? + WikiDirectory.new(dir, pages) + else + pages + end + end. + flatten + end + + def self.unhyphenize(name) + name.gsub(/-+/, ' ') + end + def to_key [:slug] end @@ -56,7 +82,7 @@ class WikiPage # The formatted title of this page. def title if @attributes[:title] - @attributes[:title].gsub(/-+/, ' ') + self.class.unhyphenize(@attributes[:title]) else "" end @@ -69,16 +95,17 @@ class WikiPage # The raw content of this page. def content - @attributes[:content] ||= if @page - @page.text_data - end + @attributes[:content] ||= @page&.text_data + end + + # The hierarchy of the directory this page is contained in. + def directory + wiki.page_title_and_dir(slug).last end # The processed/formatted content of this page. def formatted_content - @attributes[:formatted_content] ||= if @page - @page.formatted_data - end + @attributes[:formatted_content] ||= @page&.formatted_data end # The markup format for the page. @@ -174,6 +201,16 @@ class WikiPage end end + # Relative path to the partial to be used when rendering collections + # of this object. + def to_partial_path + 'projects/wikis/wiki_page' + end + + def id + page.version.to_s + end + private def set_attributes diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index f5fd50745aa..f8594e29547 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -218,25 +218,7 @@ class ProjectPolicy < BasePolicy def anonymous_rules return unless project.public? - can! :read_project - can! :read_board - can! :read_list - can! :read_wiki - can! :read_label - can! :read_milestone - can! :read_project_snippet - can! :read_project_member - can! :read_merge_request - can! :read_note - can! :read_pipeline - can! :read_commit_status - can! :read_container_image - can! :download_code - can! :download_wiki_code - can! :read_cycle_analytics - - # NOTE: may be overridden by IssuePolicy - can! :read_issue + base_readonly_access! # Allow to read builds by anonymous user if guests are allowed can! :read_build if project.public_builds? @@ -269,4 +251,31 @@ class ProjectPolicy < BasePolicy :"admin_#{name}" ] end + + private + + # A base set of abilities for read-only users, which + # is then augmented as necessary for anonymous and other + # read-only users. + def base_readonly_access! + can! :read_project + can! :read_board + can! :read_list + can! :read_wiki + can! :read_label + can! :read_milestone + can! :read_project_snippet + can! :read_project_member + can! :read_merge_request + can! :read_note + can! :read_pipeline + can! :read_commit_status + can! :read_container_image + can! :download_code + can! :download_wiki_code + can! :read_cycle_analytics + + # NOTE: may be overridden by IssuePolicy + can! :read_issue + end end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 57acccfafd9..3a96836917e 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -3,7 +3,7 @@ class ProjectSnippetPolicy < BasePolicy can! :read_project_snippet if @subject.public? return unless @user - if @user && @subject.author == @user || @user.admin? + if @user && (@subject.author == @user || @user.admin?) can! :read_project_snippet can! :update_project_snippet can! :admin_project_snippet diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 5d15eb8d3d3..4c017960628 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -7,7 +7,7 @@ class EnvironmentEntity < Grape::Entity expose :external_url expose :environment_type expose :last_deployment, using: DeploymentEntity - expose :stoppable? + expose :stop_action? expose :environment_path do |environment| namespace_project_environment_path( diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 91955542f25..fe16a3784c4 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -1,3 +1,50 @@ class EnvironmentSerializer < BaseSerializer + Item = Struct.new(:name, :size, :latest) + entity EnvironmentEntity + + def within_folders + tap { @itemize = true } + end + + def with_pagination(request, response) + tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } + end + + def itemized? + @itemize + end + + def paginated? + @paginator.present? + end + + def represent(resource, opts = {}) + resource = @paginator.paginate(resource) if paginated? + + if itemized? + itemize(resource).map do |item| + { name: item.name, + size: item.size, + latest: super(item.latest, opts) } + end + else + super(resource, opts) + end + end + + private + + def itemize(resource) + items = resource.group(:item_name).order('item_name ASC') + .pluck('COALESCE(environment_type, name) AS item_name', + 'COUNT(*) AS environments_count', + 'MAX(id) AS last_environment_id') + + environments = resource.where(id: items.map(&:last)).index_by(&:id) + + items.map do |name, size, id| + Item.new(name, size, environments[id]) + end + end end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index b2de6c5832e..2bc6cf3266e 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -1,41 +1,25 @@ class PipelineSerializer < BaseSerializer class InvalidResourceError < StandardError; end - include API::Helpers::Pagination - Struct.new('Pagination', :request, :response) entity PipelineEntity - def represent(resource, opts = {}) - if paginated? - raise InvalidResourceError unless resource.respond_to?(:page) - - super(paginate(resource.includes(project: :namespace)), opts) - else - super(resource, opts) - end - end - - def paginated? - defined?(@pagination) - end - def with_pagination(request, response) - tap { @pagination = Struct::Pagination.new(request, response) } + tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } end - private - - # Methods needed by `API::Helpers::Pagination` - # - def params - @pagination.request.query_parameters + def paginated? + @paginator.present? end - def request - @pagination.request - end + def represent(resource, opts = {}) + if resource.is_a?(ActiveRecord::Relation) + resource = resource.includes(project: :namespace) + end - def header(header, value) - @pagination.response.headers[header] = value + if paginated? + super(@paginator.paginate(resource), opts) + else + super(resource, opts) + end end end diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index cf590459cb2..42c72aba7dd 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -8,10 +8,9 @@ module Ci return unless has_ref? environments.each do |environment| - next unless environment.stoppable? next unless can?(current_user, :create_deployment, project) - environment.stop!(current_user) + environment.stop_with_action!(current_user) end end @@ -22,8 +21,8 @@ module Ci end def environments - @environments ||= project - .environments_recently_updated_on_branch(@ref) + @environments ||= + EnvironmentsFinder.new(project, current_user, ref: @ref, recently_updated: true).execute end end end diff --git a/app/services/create_tag_service.rb b/app/services/create_tag_service.rb index fe9353afeb8..6c75d1f04ff 100644 --- a/app/services/create_tag_service.rb +++ b/app/services/create_tag_service.rb @@ -4,7 +4,7 @@ class CreateTagService < BaseService return error('Tag name invalid') unless valid_tag repository = project.repository - message.strip! if message + message&.strip! new_tag = nil diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb index 9d4bffb93e9..eb726cb04b1 100644 --- a/app/services/delete_tag_service.rb +++ b/app/services/delete_tag_service.rb @@ -9,7 +9,7 @@ class DeleteTagService < BaseService if repository.rm_tag(current_user, tag_name) release = project.releases.find_by(tag: tag_name) - release.destroy if release + release&.destroy push_data = build_push_data(tag) EventCreateService.new.push(project, current_user, push_data) diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb deleted file mode 100644 index eaff88d6463..00000000000 --- a/app/services/delete_user_service.rb +++ /dev/null @@ -1,31 +0,0 @@ -class DeleteUserService - attr_accessor :current_user - - def initialize(current_user) - @current_user = current_user - end - - def execute(user, options = {}) - if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present? - user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' - return user - end - - user.solo_owned_groups.each do |group| - DestroyGroupService.new(group, current_user).execute - end - - user.personal_projects.each do |project| - # Skip repository removal because we remove directory with namespace - # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).async_execute - end - - # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing - namespace = user.namespace - user_data = user.destroy - namespace.really_destroy! - - user_data - end -end diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb deleted file mode 100644 index 2316c57bf1e..00000000000 --- a/app/services/destroy_group_service.rb +++ /dev/null @@ -1,29 +0,0 @@ -class DestroyGroupService - attr_accessor :group, :current_user - - def initialize(group, user) - @group, @current_user = group, user - end - - def async_execute - # Soft delete via paranoia gem - group.destroy - job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) - Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") - end - - def execute - group.projects.each do |project| - # Execute the destruction of the models immediately to ensure atomic cleanup. - # Skip repository removal because we remove directory with namespace - # that contain all these repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute - end - - group.children.each do |group| - DestroyGroupService.new(group, current_user).async_execute - end - - group.really_destroy! - end -end diff --git a/app/services/files/delete_service.rb b/app/services/files/destroy_service.rb index 50f0ffcac9f..c3be806a42d 100644 --- a/app/services/files/delete_service.rb +++ b/app/services/files/destroy_service.rb @@ -1,5 +1,5 @@ module Files - class DeleteService < Files::BaseService + class DestroyService < Files::BaseService def commit repository.remove_file( current_user, diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb new file mode 100644 index 00000000000..7f2d28086f5 --- /dev/null +++ b/app/services/groups/destroy_service.rb @@ -0,0 +1,25 @@ +module Groups + class DestroyService < Groups::BaseService + def async_execute + # Soft delete via paranoia gem + group.destroy + job_id = GroupDestroyWorker.perform_async(group.id, current_user.id) + Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}") + end + + def execute + group.projects.each do |project| + # Execute the destruction of the models immediately to ensure atomic cleanup. + # Skip repository removal because we remove directory with namespace + # that contain all these repositories + ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute + end + + group.children.each do |group| + DestroyService.new(group, current_user).async_execute + end + + group.really_destroy! + end + end +end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index a63982f60c8..7cd927d8005 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -44,7 +44,15 @@ module Issues end def issue_params - @issue_params ||= issue_params_with_info_from_merge_request.merge(params.slice(:title, :description)) + @issue_params ||= issue_params_with_info_from_merge_request.merge(whitelisted_issue_params) + end + + def whitelisted_issue_params + if can?(current_user, :admin_issue, project) + params.slice(:title, :description, :milestone_id) + else + params.slice(:title, :description) + end end end end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index d2eb46ac41b..961605a1005 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -3,6 +3,8 @@ module Issues def execute @request = params.delete(:request) @api = params.delete(:api) + @recaptcha_verified = params.delete(:recaptcha_verified) + @spam_log_id = params.delete(:spam_log_id) issue_attributes = params.merge(merge_request_for_resolving_discussions: merge_request_for_resolving_discussions) @issue = BuildService.new(project, current_user, issue_attributes).execute @@ -11,7 +13,13 @@ module Issues end def before_create(issuable) - issuable.spam = spam_service.check(@api) + if @recaptcha_verified + spam_log = current_user.spam_logs.find_by(id: @spam_log_id, title: issuable.title) + spam_log&.update!(recaptcha_verified: true) + else + issuable.spam = spam_service.check(@api) + issuable.spam_log = spam_service.spam_log + end end def after_create(issuable) @@ -35,7 +43,7 @@ module Issues private def spam_service - SpamService.new(@issue, @request) + @spam_service ||= SpamService.new(@issue, @request) end def user_agent_detail_service diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index b4bfb0e5e8c..581d18032e6 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -144,7 +144,11 @@ module MergeRequests return unless @commits.present? merge_requests_for_source_branch.each do |merge_request| - wip_commit = @commits.detect(&:work_in_progress?) + commit_shas = merge_request.commits_sha + + wip_commit = @commits.detect do |commit| + commit.work_in_progress? && commit_shas.include?(commit.sha) + end if wip_commit && !merge_request.work_in_progress? merge_request.update(title: merge_request.wip_title) diff --git a/app/services/notes/delete_service.rb b/app/services/notes/destroy_service.rb index a673e8e9dde..b819bd17039 100644 --- a/app/services/notes/delete_service.rb +++ b/app/services/notes/destroy_service.rb @@ -1,5 +1,5 @@ module Notes - class DeleteService < BaseService + class DestroyService < BaseService def execute(note) note.destroy end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index b2cc39763f3..3734e3c4253 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) + recipients.delete(note.author) unless note.author.notified_of_own_activity? recipients = recipients.uniq notify_method = "note_#{note.to_ability_name}_email".to_sym @@ -327,8 +327,9 @@ class NotificationService recipients ||= build_recipients( pipeline, pipeline.project, - nil, # The acting user, who won't be added to recipients - action: pipeline.status).map(&:notification_email) + pipeline.user, + action: pipeline.status, + skip_current_user: false).map(&:notification_email) if recipients.any? mailer.public_send(email_template, pipeline, recipients).deliver_later @@ -627,7 +628,7 @@ class NotificationService recipients = reject_unsubscribed_users(recipients, target) recipients = reject_users_without_access(recipients, target) - recipients.delete(current_user) if skip_current_user + recipients.delete(current_user) if skip_current_user && !current_user.notified_of_own_activity? recipients.uniq end @@ -636,7 +637,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) + recipients.delete(current_user) unless current_user.notified_of_own_activity? recipients.uniq end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index c7cce0c55b9..6dc3d8c2416 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -97,7 +97,7 @@ module Projects @project.team << [current_user, :master, current_user] end - @project.group.refresh_members_authorized_projects if @project.group + @project.group&.refresh_members_authorized_projects end def skip_wiki? diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index 06252c7b625..535da706159 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -26,7 +26,7 @@ module Projects end def project_tree_saver - Gitlab::ImportExport::ProjectTreeSaver.new(project: project, shared: @shared) + Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared) end def uploads_saver diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 96c363c8d1a..e6193fcacee 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -36,7 +36,7 @@ module Projects def groups current_user.authorized_groups.sort_by(&:path).map do |group| count = group.users.count - { username: group.path, name: group.name, count: count, avatar_url: group.avatar_url } + { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url } end end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 20b049b5973..484700c8c29 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -25,9 +25,10 @@ module Projects end def transfer(project, new_namespace) + old_namespace = project.namespace + Project.transaction do old_path = project.path_with_namespace - old_namespace = project.namespace old_group = project.group new_path = File.join(new_namespace.try(:path) || '', project.path) @@ -70,8 +71,11 @@ module Projects project.old_path_with_namespace = old_path SystemHooksService.new.execute_hooks_for(project, :transfer) - true end + + refresh_permissions(old_namespace, new_namespace) + + true end def allowed_transfer?(current_user, project, namespace) @@ -80,5 +84,14 @@ module Projects namespace.id != project.namespace_id && current_user.can?(:create_projects, namespace) end + + def refresh_permissions(old_namespace, new_namespace) + # This ensures we only schedule 1 job for every user that has access to + # the namespaces. + user_ids = old_namespace.user_ids_for_project_authorizations | + new_namespace.user_ids_for_project_authorizations + + UserProjectAccessChangedService.new(user_ids).execute + end end end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb index 48903291799..024a7c19d33 100644 --- a/app/services/spam_service.rb +++ b/app/services/spam_service.rb @@ -1,5 +1,6 @@ class SpamService attr_accessor :spammable, :request, :options + attr_reader :spam_log def initialize(spammable, request = nil) @spammable = spammable @@ -63,7 +64,7 @@ class SpamService end def create_spam_log(api) - SpamLog.create( + @spam_log = SpamLog.create!( { user_id: spammable_owner_id, title: spammable.spam_title, diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 110072e3a16..87ba72cf991 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -385,6 +385,7 @@ module SystemNoteService # Returns Boolean def cross_reference_disallowed?(noteable, mentioner) return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active? + return true if noteable.is_a?(Issuable) && (noteable.try(:closed?) || noteable.try(:merged?)) return false unless mentioner.is_a?(MergeRequest) return false unless noteable.is_a?(Commit) diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 1bd6ce416ab..8ab943f4639 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -243,6 +243,12 @@ class TodoService end def create_mention_todos(project, target, author, note = nil) + # Create Todos for directly addressed users + directly_addressed_users = filter_directly_addressed_users(project, note || target, author) + attributes = attributes_for_todo(project, target, author, Todo::DIRECTLY_ADDRESSED, note) + create_todos(directly_addressed_users, attributes) + + # Create Todos for mentioned users mentioned_users = filter_mentioned_users(project, note || target, author) attributes = attributes_for_todo(project, target, author, Todo::MENTIONED, note) create_todos(mentioned_users, attributes) @@ -282,10 +288,18 @@ class TodoService ) end + def filter_todo_users(users, project, target) + reject_users_without_access(users, project, target).uniq + end + def filter_mentioned_users(project, target, author) mentioned_users = target.mentioned_users(author) - mentioned_users = reject_users_without_access(mentioned_users, project, target) - mentioned_users.uniq + filter_todo_users(mentioned_users, project, target) + end + + def filter_directly_addressed_users(project, target, author) + directly_addressed_users = target.directly_addressed_users(author) + filter_todo_users(directly_addressed_users, project, target) end def reject_users_without_access(users, project, target) diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb new file mode 100644 index 00000000000..2d11305be13 --- /dev/null +++ b/app/services/users/destroy_service.rb @@ -0,0 +1,33 @@ +module Users + class DestroyService + attr_accessor :current_user + + def initialize(current_user) + @current_user = current_user + end + + def execute(user, options = {}) + if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present? + user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' + return user + end + + user.solo_owned_groups.each do |group| + Groups::DestroyService.new(group, current_user).execute + end + + user.personal_projects.each do |project| + # Skip repository removal because we remove directory with namespace + # that contain all this repositories + ::Projects::DestroyService.new(project, current_user, skip_repo: true).async_execute + end + + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing + namespace = user.namespace + user_data = user.destroy + namespace.really_destroy! + + user_data + end + end +end diff --git a/app/services/wiki_pages/destroy_service.rb b/app/services/wiki_pages/destroy_service.rb new file mode 100644 index 00000000000..6b93fb2f6d7 --- /dev/null +++ b/app/services/wiki_pages/destroy_service.rb @@ -0,0 +1,11 @@ +module WikiPages + class DestroyService < WikiPages::BaseService + def execute(page) + if page&.delete + execute_hooks(page, 'delete') + end + + page + end + end +end diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml index 0a954c20fcd..13d00dd1fcb 100644 --- a/app/views/admin/logs/show.html.haml +++ b/app/views/admin/logs/show.html.haml @@ -18,7 +18,7 @@ .tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), id: klass::file_name_noext } .file-holder#README - .file-title + .js-file-title.file-title %i.fa.fa-file = klass::file_name .pull-right diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 4ce4eab8753..33f6d847782 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -14,6 +14,8 @@ %td = spam_log.via_api? ? 'Y' : 'N' %td + = spam_log.recaptcha_verified ? 'Y' : 'N' + %td = spam_log.noteable_type %td = spam_log.title diff --git a/app/views/admin/spam_logs/index.html.haml b/app/views/admin/spam_logs/index.html.haml index 0fdd5bd9960..8aaa6379730 100644 --- a/app/views/admin/spam_logs/index.html.haml +++ b/app/views/admin/spam_logs/index.html.haml @@ -10,6 +10,7 @@ %th User %th Source IP %th API? + %th Recaptcha verified? %th Type %th Title %th Description diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml new file mode 100644 index 00000000000..7855239dfe5 --- /dev/null +++ b/app/views/admin/users/_access_levels.html.haml @@ -0,0 +1,37 @@ +%fieldset + %legend Access + .form-group + = f.label :projects_limit, class: 'control-label' + .col-sm-10= f.number_field :projects_limit, min: 0, class: 'form-control' + + .form-group + = f.label :can_create_group, class: 'control-label' + .col-sm-10= f.check_box :can_create_group + + .form-group + = f.label :access_level, class: 'control-label' + .col-sm-10 + - editing_current_user = (current_user == @user) + + = f.radio_button :access_level, :regular, disabled: editing_current_user + = label_tag :regular do + Regular + %p.light + Regular users have access to their groups and projects + + = f.radio_button :access_level, :admin, disabled: editing_current_user + = label_tag :admin do + Admin + %p.light + Administrators have access to all groups, projects and users and can manage all features in this installation + - if editing_current_user + %p.light + You cannot remove your own admin rights. + + .form-group + = f.label :external, class: 'control-label' + .col-sm-10 + = f.check_box :external do + External + %p.light + External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects or groups. diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 3145212728f..e911af3f6f9 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -40,28 +40,7 @@ = f.label :password_confirmation, class: 'control-label' .col-sm-10= f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control' - %fieldset - %legend Access - .form-group - = f.label :projects_limit, class: 'control-label' - .col-sm-10= f.number_field :projects_limit, min: 0, class: 'form-control' - - .form-group - = f.label :can_create_group, class: 'control-label' - .col-sm-10= f.check_box :can_create_group - - .form-group - = f.label :admin, class: 'control-label' - - if current_user == @user - .col-sm-10= f.check_box :admin, disabled: true - .col-sm-10 You cannot remove your own admin rights. - - else - .col-sm-10= f.check_box :admin - - .form-group - = f.label :external, class: 'control-label' - .col-sm-10= f.check_box :external - .col-sm-10 External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects or groups. + = render partial: 'access_levels', locals: { f: f } %fieldset %legend Profile diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml index b0bee1c6204..dfbc7772698 100644 --- a/app/views/ci/lints/show.html.haml +++ b/app/views/ci/lints/show.html.haml @@ -11,7 +11,7 @@ .form-group .col-sm-12 .file-holder - .file-title.clearfix + .js-file-title.file-title.clearfix Content of .gitlab-ci.yml #ci-editor.ci-editor= @content = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true) diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml index 02b94beee92..68a46f61eb7 100644 --- a/app/views/dashboard/_activity_head.html.haml +++ b/app/views/dashboard/_activity_head.html.haml @@ -1,7 +1,8 @@ -%ul.nav-links - %li{ class: ("active" unless params[:filter]) }> - = link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do - Your Projects - %li{ class: ("active" if params[:filter] == 'starred') }> - = link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do - Starred Projects +.top-area + %ul.nav-links + %li{ class: ("active" unless params[:filter]) }> + = link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do + Your Projects + %li{ class: ("active" if params[:filter] == 'starred') }> + = link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do + Starred Projects diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 01ecf237925..5a44ec45b7b 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -23,7 +23,7 @@ = f.password_field :password, class: "form-control bottom", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters." %p.gl-field-hint Minimum length is #{@minimum_password_length} characters %div - - if current_application_settings.recaptcha_enabled + - if Gitlab::Recaptcha.enabled? = recaptcha_tags %div = f.submit "Register", class: "btn-register btn" diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 3a95a652810..94408b92374 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -2,7 +2,7 @@ - blob = discussion.blob .diff-file.file-holder - .file-title + .js-file-title.file-title = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_diff_path(discussion) .diff-content.code.js-syntax-highlight diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml index f0b61e0f7de..e30ee1b0e05 100644 --- a/app/views/discussions/_resolve_all.html.haml +++ b/app/views/discussions/_resolve_all.html.haml @@ -1,6 +1,5 @@ - if discussion.for_merge_request? - %resolve-discussion-btn{ ":project-path" => "'#{project_path(discussion.project)}'", - ":discussion-id" => "'#{discussion.id}'", + %resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", ":merge-request-id" => discussion.noteable.iid, ":can-resolve" => discussion.can_resolve?(current_user), "inline-template" => true } diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml new file mode 100644 index 00000000000..41f54f6bf42 --- /dev/null +++ b/app/views/groups/_home_panel.html.haml @@ -0,0 +1,17 @@ +.group-home-panel.text-center + %div{ class: container_class } + .avatar-container.s70.group-avatar + = image_tag group_icon(@group), class: "avatar s70 avatar-tile" + %h1.group-title + @#{@group.path} + %span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } + = visibility_level_icon(@group.visibility_level, fw: false) + + - if @group.description.present? + .group-home-desc + = markdown_field(@group, :description) + + - if current_user + .group-buttons + = render 'shared/members/access_request_buttons', source: @group + = render 'shared/notifications/button', notification_setting: @notification_setting diff --git a/app/views/groups/_show_nav.html.haml b/app/views/groups/_show_nav.html.haml new file mode 100644 index 00000000000..b2097e88741 --- /dev/null +++ b/app/views/groups/_show_nav.html.haml @@ -0,0 +1,7 @@ +%ul.nav-links + = nav_link(page: group_path(@group)) do + = link_to group_path(@group) do + Projects + = nav_link(page: subgroups_group_path(@group)) do + = link_to subgroups_group_path(@group) do + Subgroups diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index fb6f0da28f8..e66a8e0a3b3 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -1,4 +1,8 @@ = render "header_title" + +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? + = render 'shared/milestones/top', milestone: @milestone, group: @group = render 'shared/milestones/summary', milestone: @milestone = render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index d256d14609e..b040f404ac4 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -4,38 +4,12 @@ - if current_user = auto_discovery_link_tag(:atom, group_url(@group, format: :atom, private_token: current_user.private_token), title: "#{@group.name} activity") -.group-home-panel.text-center - %div{ class: container_class } - .avatar-container.s70.group-avatar - = image_tag group_icon(@group), class: "avatar s70 avatar-tile" - %h1.group-title - @#{@group.path} - %span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } - = visibility_level_icon(@group.visibility_level, fw: false) += render 'groups/home_panel' - - if @group.description.present? - .group-home-desc - = markdown_field(@group, :description) - - - if current_user - .group-buttons - = render 'shared/members/access_request_buttons', source: @group - = render 'shared/notifications/button', notification_setting: @notification_setting .groups-header{ class: container_class } .top-area - %ul.nav-links - %li.active - = link_to "#projects", 'data-toggle' => 'tab' do - All Projects - - if @shared_projects.present? - %li - = link_to "#shared", 'data-toggle' => 'tab' do - Shared Projects - - if @nested_groups.present? - %li - = link_to "#groups", 'data-toggle' => 'tab' do - Subgroups + = render 'groups/show_nav' .nav-controls = form_tag request.path, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false @@ -44,15 +18,4 @@ = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do New Project - .tab-content - .tab-pane.active#projects - = render "projects", projects: @projects - - - if @shared_projects.present? - .tab-pane#shared - = render "shared_projects", projects: @shared_projects - - - if @nested_groups.present? - .tab-pane#groups - %ul.content-list - = render partial: 'shared/groups/group', collection: @nested_groups + = render "projects", projects: @projects diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml new file mode 100644 index 00000000000..8610ae7e0ef --- /dev/null +++ b/app/views/groups/subgroups.html.haml @@ -0,0 +1,20 @@ +- @no_container = true + += render 'groups/home_panel' + +.groups-header{ class: container_class } + .top-area + = render 'groups/show_nav' + .nav-controls + = form_tag request.path, method: :get do |f| + = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false + - if can? current_user, :admin_group, @group + = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do + New Subgroup + + - if @nested_groups.present? + %ul.content-list + = render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false } + - else + .nothing-here-block + There are no subgroups to show. diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index da2df0d8080..705e20112fa 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -79,6 +79,14 @@ %td.shortcut .key esc %td Go back + %tbody + %tr + %th + %th Project File + %tr + %td.shortcut + .key y + %td Go to file permalink .col-lg-4 %table.shortcut-mappings diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index dd1df46792b..87f9b503989 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -528,7 +528,7 @@ - blob = Snippet.new(content: "Wow\nSuch\nFile") .example .file-holder - .file-title + .js-file-title.file-title Awesome file .file-actions .btn-group diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 9ecc0d11c95..59082ce5fd5 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,4 @@ -%header.navbar.navbar-fixed-top.navbar-gitlab{ class: nav_header_class } +%header.navbar.navbar-gitlab{ class: nav_header_class } %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content .container-fluid .header-content diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index d6c158b6de3..665725f6862 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -18,20 +18,8 @@ Protected Branches - if @project.feature_available?(:builds, current_user) - = nav_link(controller: :runners) do - = link_to namespace_project_runners_path(@project.namespace, @project), title: 'Runners' do - %span - Runners - = nav_link(controller: :variables) do - = link_to namespace_project_variables_path(@project.namespace, @project), title: 'Variables' do - %span - Variables - = nav_link(controller: :triggers) do - = link_to namespace_project_triggers_path(@project.namespace, @project), title: 'Triggers' do - %span - Triggers - = nav_link(controller: :pipelines_settings) do - = link_to namespace_project_pipelines_settings_path(@project.namespace, @project), title: 'CI/CD Pipelines' do + = nav_link(controller: :ci_cd) do + = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do %span CI/CD Pipelines = nav_link(controller: :pages) do diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 5c5e5940365..51c4e8e5a73 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -34,6 +34,11 @@ .clearfix + = form_for @user, url: profile_notifications_path, method: :put do |f| + %label{ for: 'user_notified_of_own_activity' } + = f.check_box :notified_of_own_activity + %span Receive notifications about your own activity + %hr %h5 Groups (#{@group_notifications.count}) diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 60a561c9f9c..2c006e1712d 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -85,11 +85,17 @@ :javascript - var date = $('#personal_access_token_expires_at').val(); - - var datepicker = $(".datepicker").datepicker({ - dateFormat: "yy-mm-dd", - minDate: 0 + var $dateField = $('#personal_access_token_expires_at'); + var date = $dateField.val(); + + new Pikaday({ + field: $dateField.get(0), + theme: 'gitlab-theme', + format: 'YYYY-MM-DD', + minDate: new Date(), + onSelect: function(dateText) { + $dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); + } }); $("#created-personal-access-token").click(function() { diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 1c3bccccb5c..a08436715d2 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -10,6 +10,7 @@ - if @project && event.project != @project %span at %strong= link_to_project event.project + = clipboard_button(clipboard_text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard') #{time_ago_with_tooltip(event.created_at)} .pull-right diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 23f54553014..8a40281e28c 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -7,7 +7,7 @@ #blob-content-holder.tree-holder .file-holder - .file-title + .js-file-title.file-title = blob_icon @blob.mode, @blob.name %strong = @path diff --git a/app/views/projects/blob/_actions.html.haml b/app/views/projects/blob/_actions.html.haml index ff893ea74e1..7b9cfbbd067 100644 --- a/app/views/projects/blob/_actions.html.haml +++ b/app/views/projects/blob/_actions.html.haml @@ -1,3 +1,6 @@ +.btn-group + = view_on_environment_button(@commit.sha, @path, @environment) if @environment + .btn-group.tree-btn-group = link_to 'Raw', namespace_project_raw_path(@project.namespace, @project, @id), class: 'btn btn-sm', target: '_blank' @@ -12,7 +15,7 @@ = 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' + tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' - if current_user .btn-group{ role: "group" } diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index f75f438ee4f..19fa4c78501 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -24,7 +24,7 @@ #blob-content-holder.blob-content-holder %article.file-holder - .file-title + .js-file-title.file-title = blob_icon blob.mode, blob.name %strong = blob.name diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 228ac61fc8c..e7adef5558a 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -1,5 +1,5 @@ .file-holder.file.append-bottom-default - .file-title.clearfix + .js-file-title.file-title.clearfix .editor-ref = icon('code-fork') = ref diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index 05fe504d1c9..f5ca9607823 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -4,7 +4,7 @@ - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('boards') - = page_specific_javascript_bundle_tag('boards_test') if Rails.env.test? + = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? %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" diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 56fc5f5e68b..78720d88e4e 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -1,6 +1,6 @@ - builds = @build.pipeline.builds.to_a -%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar +%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "151", "spy" => "affix" } } .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default Job %strong ##{@build.id} diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index cdab1e1b1a6..f852f2e3fd7 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -15,7 +15,7 @@ - else %span.api.monospace API - if pipeline.latest? - %span.label.label-success.has-tooltip{ title: 'Latest job for this branch' } latest + %span.label.label-success.has-tooltip{ title: 'Latest pipeline for this branch' } latest - if pipeline.triggered? %span.label.label-primary triggered - if pipeline.yaml_errors.present? @@ -40,25 +40,8 @@ - else Cant find HEAD commit for this branch - %td.stage-cell - - pipeline.stages.each do |stage| - - if stage.status - - detailed_status = stage.detailed_status(current_user) - - icon_status = "#{detailed_status.icon}_borderless" - - status_klass = "ci-status-icon ci-status-icon-#{detailed_status.group}" - - .stage-container.dropdown.js-mini-pipeline-graph - %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: stage.name) } } - = custom_icon(icon_status) - = icon('caret-down') - - %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container - .arrow-up - .js-builds-dropdown-list.scrollable-menu - - .js-builds-dropdown-loading.builds-dropdown-loading.hidden - %span.fa.fa-spinner.fa-spin - + %td + = render 'shared/mini_pipeline_graph', pipeline: pipeline, klass: 'js-mini-pipeline-graph' %td - if pipeline.duration @@ -78,7 +61,7 @@ .btn-group.inline - if actions.any? .btn-group - %button.dropdown-toggle.btn.btn-default.has-tooltip.js-pipeline-dropdown-manual-actions{ type: 'button', title: 'Manual job', data: { toggle: 'dropdown', placement: 'top' }, 'aria-label' => 'Manual job' } + %button.dropdown-toggle.btn.btn-default.has-tooltip.js-pipeline-dropdown-manual-actions{ type: 'button', title: 'Manual pipeline', data: { toggle: 'dropdown', placement: 'top' }, 'aria-label' => 'Manual pipeline' } = custom_icon('icon_play') = icon('caret-down', 'aria-hidden' => 'true') %ul.dropdown-menu.dropdown-menu-align-right diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml index 1164627fa11..aae2cb8a04b 100644 --- a/app/views/projects/commit/_pipelines_list.haml +++ b/app/views/projects/commit/_pipelines_list.haml @@ -1,15 +1,25 @@ -%div - - if pipelines.blank? - %div - .nothing-here-block No pipelines to show - - else - .table-holder.pipelines - %table.table.ci-table.js-pipeline-table - %thead - %th.pipeline-status Status - %th.pipeline-info Pipeline - %th.pipeline-commit Commit - %th.pipeline-stages Stages - %th.pipeline-date - %th.pipeline-actions - = render pipelines, commit_sha: true, stage: true, allow_retry: true, show_commit: false +#commit-pipeline-table-view{ data: { endpoint: endpoint } } +.pipeline-svgs{ data: { "commit_icon_svg" => custom_icon("icon_commit"), + "icon_status_canceled" => custom_icon("icon_status_canceled"), + "icon_status_running" => custom_icon("icon_status_running"), + "icon_status_skipped" => custom_icon("icon_status_skipped"), + "icon_status_created" => custom_icon("icon_status_created"), + "icon_status_pending" => custom_icon("icon_status_pending"), + "icon_status_success" => custom_icon("icon_status_success"), + "icon_status_failed" => custom_icon("icon_status_failed"), + "icon_status_warning" => custom_icon("icon_status_warning"), + "stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"), + "stage_icon_status_running" => custom_icon("icon_status_running_borderless"), + "stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"), + "stage_icon_status_created" => custom_icon("icon_status_created_borderless"), + "stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"), + "stage_icon_status_success" => custom_icon("icon_status_success_borderless"), + "stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"), + "stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"), + "icon_play" => custom_icon("icon_play"), + "icon_timer" => custom_icon("icon_timer"), + "icon_status_manual" => custom_icon("icon_status_manual"), +} } + +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('commit_pipelines') diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml index 89968cf4e0d..ac93eac41ac 100644 --- a/app/views/projects/commit/pipelines.html.haml +++ b/app/views/projects/commit/pipelines.html.haml @@ -2,4 +2,4 @@ = render 'commit_box' = render 'ci_menu' -= render 'pipelines_list', pipelines: @pipelines += render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id) diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 7afd3d80ef5..d5fc283aa8d 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -9,7 +9,7 @@ = render "ci_menu" - else .block-connector - = render "projects/diffs/diffs", diffs: @diffs + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment = render "projects/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index d94f23f5a38..08cb8a04413 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -22,9 +22,7 @@ = link_to "View Open Merge Request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn' - elsif create_mr_button?(@repository.root_ref, @ref) .control - = link_to create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' do - = icon('plus') - Create Merge Request + = link_to "Create Merge Request", create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' .control = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index d76d48187cd..08236216421 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -23,6 +23,4 @@ - if @merge_request.present? = link_to "View Open Merge Request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'prepend-left-10 btn' - elsif create_mr_button? - = link_to create_mr_path, class: 'prepend-left-10 btn' do - = icon("plus") - Create Merge Request + = link_to "Create Merge Request", create_mr_path, class: 'prepend-left-10 btn' diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 9c8f58d4aea..0dfc9fe20ed 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -8,7 +8,7 @@ - if @commits.present? = render "projects/commits/commit_list" - = render "projects/diffs/diffs", diffs: @diffs + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - else .light-well .center diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 58c20e225c6..4b49bed835f 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -1,3 +1,4 @@ +- environment = local_assigns.fetch(:environment, nil) - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) - can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project) - diff_files = diffs.diff_files @@ -30,4 +31,4 @@ - file_hash = hexdigest(diff_file.file_path) = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project, - diff_file: diff_file, diff_commit: diff_commit, blob: blob + diff_file: diff_file, diff_commit: diff_commit, blob: blob, environment: environment diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index fc478ccc995..0232a09b4a8 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,6 +1,8 @@ +- environment = local_assigns.fetch(:environment, nil) .diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_commit.id) } - .file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "##{file_hash}" + .js-file-title.file-title-flex-parent + .file-header-content + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "##{file_hash}" - unless diff_file.submodule? .file-actions.hidden-xs @@ -13,6 +15,7 @@ = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, blob: blob, link_opts: link_opts) - = view_file_btn(diff_commit.id, diff_file.new_path, project) + = view_file_button(diff_commit.id, diff_file.new_path, project) + = view_on_environment_button(diff_commit.id, diff_file.new_path, environment) if environment = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index ddec775b789..1dbfe830d52 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -10,13 +10,13 @@ - if diff_file.renamed_file - old_path, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) - %strong + %strong.file-title-name.has-tooltip{ data: { title: old_path, container: 'body' } } = old_path → - %strong + %strong.file-title-name.has-tooltip{ data: { title: new_path, container: 'body' } } = new_path - else - %strong + %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } } = diff_file.new_path - if diff_file.deleted_file deleted diff --git a/app/views/projects/environments/_stop.html.haml b/app/views/projects/environments/_stop.html.haml index 69848123c17..14a2d627203 100644 --- a/app/views/projects/environments/_stop.html.haml +++ b/app/views/projects/environments/_stop.html.haml @@ -1,4 +1,4 @@ -- if can?(current_user, :create_deployment, environment) && environment.stoppable? +- if can?(current_user, :create_deployment, environment) && environment.stop_action? .inline = link_to stop_namespace_project_environment_path(@project.namespace, @project, environment), method: :post, class: 'btn stop-env-link', rel: 'nofollow', data: { confirm: 'Are you sure you want to stop this environment?' } do diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 7800d6ac382..7036325fff8 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -12,7 +12,7 @@ = render 'projects/environments/external_url', environment: @environment - if can?(current_user, :update_environment, @environment) = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' - - if can?(current_user, :create_deployment, @environment) && @environment.stoppable? + - if can?(current_user, :create_deployment, @environment) && @environment.can_stop? = link_to 'Stop', stop_namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post .deployments-container diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml index 1d49e9cbaf7..ef0dd0eda3c 100644 --- a/app/views/projects/environments/terminal.html.haml +++ b/app/views/projects/environments/terminal.html.haml @@ -16,6 +16,8 @@ .col-sm-6 .nav-controls + = link_to @environment.external_url, class: 'btn btn-default' do + = icon('external-link') = render 'projects/deployments/actions', deployment: @environment.last_deployment .terminal-container{ class: container_class } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index c2f4457b60b..5d4e593e4ef 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,7 +1,7 @@ - content_for :note_actions do - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, status_only: true, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, status_only: true, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes = render 'projects/notes/notes_with_form' diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index f3be343daae..0e3902c066a 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,60 +1,46 @@ %li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } } - - if @bulk_edit - .issue-check - = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" + .issue-box + - if @bulk_edit + .issue-check + = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" + .issue-info-container + .issue-title.title + %span.issue-title-text + = confidential_icon(issue) + = link_to issue.title, issue_path(issue) + %ul.controls + - if issue.closed? + %li + CLOSED - .issue-title.title - %span.issue-title-text - = confidential_icon(issue) - = link_to issue.title, issue_path(issue) - %ul.controls - - if issue.closed? - %li - CLOSED + - if issue.assignee + %li + = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") - - if issue.assignee - %li - = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") + = render 'shared/issuable_meta_data', issuable: issue - - upvotes, downvotes = issue.upvotes, issue.downvotes - - if upvotes > 0 - %li - = icon('thumbs-up') - = upvotes + .issue-info + #{issuable_reference(issue)} · + opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} + by #{link_to_member(@project, issue.author, avatar: false)} + - if issue.milestone + + = link_to namespace_project_issues_path(issue.project.namespace, issue.project, milestone_title: issue.milestone.title) do + = icon('clock-o') + = issue.milestone.title + - if issue.due_date + %span{ class: "#{'cred' if issue.overdue?}" } + + = icon('calendar') + = issue.due_date.to_s(:medium) + - if issue.labels.any? + + - issue.labels.each do |label| + = link_to_label(label, subject: issue.project, css_class: 'label-link') + - if issue.tasks? + + %span.task-status + = issue.task_status - - if downvotes > 0 - %li - = icon('thumbs-down') - = downvotes - - - note_count = issue.notes.user.count - %li - = link_to issue_path(issue, anchor: 'notes'), class: ('no-comments' if note_count.zero?) do - = icon('comments') - = note_count - - .issue-info - #{issuable_reference(issue)} · - opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} - by #{link_to_member(@project, issue.author, avatar: false)} - - if issue.milestone - - = link_to namespace_project_issues_path(issue.project.namespace, issue.project, milestone_title: issue.milestone.title) do - = icon('clock-o') - = issue.milestone.title - - if issue.due_date - %span{ class: "#{'cred' if issue.overdue?}" } - - = icon('calendar') - = issue.due_date.to_s(:medium) - - if issue.labels.any? - - - issue.labels.each do |label| - = link_to_label(label, subject: issue.project) - - if issue.tasks? - - %span.task-status - = issue.task_status - - .pull-right.issue-updated-at - %span updated #{time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago')} + .pull-right.issue-updated-at + %span updated #{time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago')} diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index a2305f4f547..d3eb3b7055b 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -35,9 +35,9 @@ = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' - if can?(current_user, :update_issue, @issue) %li - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' %li - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) - if @issue.submittable_as_spam? && current_user.admin? @@ -48,8 +48,8 @@ = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - if @issue.submittable_as_spam? && current_user.admin? = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' diff --git a/app/views/projects/issues/verify.html.haml b/app/views/projects/issues/verify.html.haml new file mode 100644 index 00000000000..1934b18c086 --- /dev/null +++ b/app/views/projects/issues/verify.html.haml @@ -0,0 +1,20 @@ +- page_title "Anti-spam verification" + +%h3.page-title + Anti-spam verification +%hr + +%p + We detected potential spam in the issue description. Please verify that you are not a robot to submit the issue. + += form_for [@project.namespace.becomes(Namespace), @project, @issue] do |f| + .recaptcha + - params[:issue].each do |field, value| + = hidden_field(:issue, field, value: value) + = hidden_field_tag(:merge_request_for_resolving_discussions, params[:merge_request_for_resolving_discussions]) + = hidden_field_tag(:spam_log_id, @issue.spam_log.id) + = hidden_field_tag(:recaptcha_verification, true) + = recaptcha_tags + + .row-content-block.footer-block + = f.submit "Submit #{@issue.class.model_name.human.downcase}", class: 'btn btn-create' diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 29f861c09c6..8d4a91cb64c 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -3,6 +3,9 @@ - hide_class = '' = render "projects/issues/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? + - if @labels.exists? || @prioritized_labels.exists? %div{ class: container_class } .top-area.adjust diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 513f0818169..11b7aaec704 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -3,73 +3,59 @@ .issue-check = check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue" - .merge-request-title.title - %span.merge-request-title-text - = link_to merge_request.title, merge_request_path(merge_request) - %ul.controls - - if merge_request.merged? - %li - MERGED - - elsif merge_request.closed? - %li - = icon('ban') - CLOSED - - - if merge_request.head_pipeline - %li - = render_pipeline_status(merge_request.head_pipeline) - - - if merge_request.open? && merge_request.broken? - %li - = link_to merge_request_path(merge_request), class: "has-tooltip", title: "Cannot be merged automatically", data: { container: 'body' } do - = icon('exclamation-triangle') - - - if merge_request.assignee - %li - = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: "Assigned to :name") - - - upvotes, downvotes = merge_request.upvotes, merge_request.downvotes - - if upvotes > 0 - %li - = icon('thumbs-up') - = upvotes - - - if downvotes > 0 - %li - = icon('thumbs-down') - = downvotes - - - note_count = merge_request.related_notes.user.count - %li - = link_to merge_request_path(merge_request, anchor: 'notes'), class: ('no-comments' if note_count.zero?) do - = icon('comments') - = note_count - - .merge-request-info - #{issuable_reference(merge_request)} · - opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} - by #{link_to_member(@project, merge_request.author, avatar: false)} - - if merge_request.target_project.default_branch != merge_request.target_branch - - = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do - = icon('code-fork') - = merge_request.target_branch - - - if merge_request.milestone - - = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do - = icon('clock-o') - = merge_request.milestone.title - - - if merge_request.labels.any? - - - merge_request.labels.each do |label| - = link_to_label(label, subject: merge_request.project, type: :merge_request) - - - if merge_request.tasks? - - %span.task-status - = merge_request.task_status - - .pull-right.hidden-xs - %span updated #{time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago')} + .issue-info-container + .merge-request-title.title + %span.merge-request-title-text + = link_to merge_request.title, merge_request_path(merge_request) + %ul.controls + - if merge_request.merged? + %li + MERGED + - elsif merge_request.closed? + %li + = icon('ban') + CLOSED + + - if merge_request.head_pipeline + %li + = render_pipeline_status(merge_request.head_pipeline) + + - if merge_request.open? && merge_request.broken? + %li + = link_to merge_request_path(merge_request), class: "has-tooltip", title: "Cannot be merged automatically", data: { container: 'body' } do + = icon('exclamation-triangle') + + - if merge_request.assignee + %li + = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: "Assigned to :name") + + = render 'shared/issuable_meta_data', issuable: merge_request + + .merge-request-info + #{issuable_reference(merge_request)} · + opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} + by #{link_to_member(@project, merge_request.author, avatar: false)} + - if merge_request.target_project.default_branch != merge_request.target_branch + + = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do + = icon('code-fork') + = merge_request.target_branch + + - if merge_request.milestone + + = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do + = icon('clock-o') + = merge_request.milestone.title + + - if merge_request.labels.any? + + - merge_request.labels.each do |label| + = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link') + + - if merge_request.tasks? + + %span.task-status + = merge_request.task_status + + .pull-right.hidden-xs + %span updated #{time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago')} diff --git a/app/views/projects/merge_requests/_new_diffs.html.haml b/app/views/projects/merge_requests/_new_diffs.html.haml index 74367ab9b7b..627fc4e9671 100644 --- a/app/views/projects/merge_requests/_new_diffs.html.haml +++ b/app/views/projects/merge_requests/_new_diffs.html.haml @@ -1 +1 @@ -= render "projects/diffs/diffs", diffs: @diffs, show_whitespace_toggle: false += render "projects/diffs/diffs", diffs: @diffs, environment: @environment, show_whitespace_toggle: false diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index d3c013b3f21..bd72310c16b 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -46,7 +46,7 @@ -# This tab is always loaded via AJAX - if @pipelines.any? #pipelines.pipelines.tab-pane - = render "projects/merge_requests/show/pipelines" + = render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json)) .mr-loading-status = spinner diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index b46c4a13cc4..dd615d3036c 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -3,10 +3,9 @@ - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_vue') = page_specific_javascript_bundle_tag('diff_notes') -.merge-request{ 'data-url' => merge_request_path(@merge_request) } +.merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) } = render "projects/merge_requests/show/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } @@ -94,7 +93,8 @@ #commits.commits.tab-pane -# This tab is always loaded via AJAX #pipelines.pipelines.tab-pane - -# This tab is always loaded via AJAX + - if @pipelines.any? + = render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) #diffs.diffs.tab-pane -# This tab is always loaded via AJAX diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml index dcf578b85f9..1ecd9924d88 100644 --- a/app/views/projects/merge_requests/conflicts.html.haml +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -23,7 +23,7 @@ .files-wrapper{ "v-if" => "!isLoading && !hasError" } .files .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" } - .file-title + .js-file-title.file-title %i.fa.fa-fw{ ":class" => "file.iconClass" } %strong {{file.filePath}} = render partial: 'projects/merge_requests/conflicts/file_actions' diff --git a/app/views/projects/merge_requests/show/_diffs.html.haml b/app/views/projects/merge_requests/show/_diffs.html.haml index 5f048d04b27..7f0913ea516 100644 --- a/app/views/projects/merge_requests/show/_diffs.html.haml +++ b/app/views/projects/merge_requests/show/_diffs.html.haml @@ -1,5 +1,5 @@ - if @merge_request_diff.collected? || @merge_request_diff.overflow? = render 'projects/merge_requests/show/versions' - = render "projects/diffs/diffs", diffs: @diffs + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - elsif @merge_request_diff.empty? .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch} diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml index afe3f3430c6..de4aa255bbd 100644 --- a/app/views/projects/merge_requests/show/_pipelines.html.haml +++ b/app/views/projects/merge_requests/show/_pipelines.html.haml @@ -1 +1,3 @@ -= render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true +- endpoint_path = local_assigns[:endpoint] || pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, format: :json) + += render 'projects/commit/pipelines_list', endpoint: endpoint_path diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index ae134563ead..e3062f47788 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -1,16 +1,21 @@ - if @pipeline .mr-widget-heading - %w[success success_with_warnings skipped canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status} ci-status-icon-#{status}", style: ("display:none" unless @pipeline.status == status) } - = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do - = ci_icon_for_status(status) + .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } + %div{ class: "ci-status-icon-#{status}" } + = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do + = ci_icon_for_status(status) %span Pipeline = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'pipeline' = ci_label_for_status(status) - for - = succeed "." do - = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace js-commit-link" + - if @pipeline.stages.any? + .mr-widget-pipeline-graph + = render 'shared/mini_pipeline_graph', pipeline: @pipeline, klass: 'js-pipeline-inline-mr-widget-graph' + %span + for + = succeed "." do + = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace js-commit-link" %span.ci-coverage - elsif @merge_request.has_ci? diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml index 5de59473840..0b0fb7854c2 100644 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ b/app/views/projects/merge_requests/widget/_show.html.haml @@ -16,13 +16,13 @@ gitlab_icon: "#{asset_path 'gitlab_logo.png'}", ci_status: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.status : ''}", ci_message: { - normal: "Job {{status}} for \"{{title}}\"", - preparing: "{{status}} job for \"{{title}}\"" + normal: "Pipeline {{status}} for \"{{title}}\"", + preparing: "{{status}} pipeline for \"{{title}}\"" }, ci_enable: #{@project.ci_service ? "true" : "false"}, ci_title: { - preparing: "{{status}} job", - normal: "Job {{status}}" + preparing: "{{status}} pipeline", + normal: "Pipeline {{status}}" }, ci_sha: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.short_sha : ''}", ci_pipeline: #{@merge_request.head_pipeline.try(:id).to_json}, diff --git a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml b/app/views/projects/merge_requests/widget/open/_build_failed.html.haml index a18c2ad768f..3979d5fa8ed 100644 --- a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml +++ b/app/views/projects/merge_requests/widget/open/_build_failed.html.haml @@ -1,6 +1,6 @@ %h4 = icon('exclamation-triangle') - The job for this merge request failed + The pipeline for this merge request failed %p Please retry the job or push a new commit to fix the failure. diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index c3a6096aa54..06a31698ee6 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -3,6 +3,9 @@ - page_description @milestone.description = render "projects/issues/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? + %div{ class: container_class } .detail-page-header.milestone-page-header .status-box{ class: status_box_class(@milestone) } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index cd685f7d0eb..41473fae4de 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -94,9 +94,8 @@ .form-group.project-visibility-level-holder = f.label :visibility_level, class: 'label-light' do Visibility Level - = link_to "(?)", 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 - + = 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 = 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/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 09339e520dd..e58de9f0e18 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -9,9 +9,12 @@ = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40' .timeline-content .note-header - = link_to_member(note.project, note.author, avatar: false) - .note-headline-light + %a.visible-xs{ href: user_path(note.author) } = note.author.to_reference + = link_to_member(note.project, note.author, avatar: false, extra_class: 'hidden-xs') + .note-headline-light + %span.hidden-xs + = note.author.to_reference - unless note.system commented - if note.system @@ -23,12 +26,11 @@ .note-actions - access = note_max_access_for_user(note) - if access - %span.note-role.hidden-xs= access + %span.note-role= access - if note.resolvable? - can_resolve = can?(current_user, :resolve_note, note) - %resolve-btn{ "project-path" => "#{project_path(note.project)}", - "discussion-id" => "#{note.discussion_id}", + %resolve-btn{ "discussion-id" => "#{note.discussion_id}", ":note-id" => note.id, ":resolved" => note.resolved?, ":can-resolve" => can_resolve, @@ -59,7 +61,7 @@ - if note_editable = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do = icon('trash-o', class: 'danger-highlight') .note-body{ class: note_editable ? 'js-task-list-container' : '' } .note-text.md diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/projects/notes/_notes_with_form.html.haml index fbd2bff5bbb..08c73d94a09 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/projects/notes/_notes_with_form.html.haml @@ -13,7 +13,7 @@ = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form = render "projects/notes/form", view: diff_view - - else + - elsif !current_user .disabled-comment.text-center .disabled-comment-text.inline Please diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index f776734556a..81e393d7626 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -36,31 +36,27 @@ = link_to ci_lint_path, class: 'btn btn-default' do %span CI Lint .content-list.pipelines{ data: { url: namespace_project_pipelines_path(@project.namespace, @project, format: :json) } } - - if @pipelines.blank? - %div - .nothing-here-block No pipelines to show - - else - .pipeline-svgs{ "data" => {"commit_icon_svg" => custom_icon("icon_commit"), - "icon_status_canceled" => custom_icon("icon_status_canceled"), - "icon_status_running" => custom_icon("icon_status_running"), - "icon_status_skipped" => custom_icon("icon_status_skipped"), - "icon_status_created" => custom_icon("icon_status_created"), - "icon_status_pending" => custom_icon("icon_status_pending"), - "icon_status_success" => custom_icon("icon_status_success"), - "icon_status_failed" => custom_icon("icon_status_failed"), - "icon_status_warning" => custom_icon("icon_status_warning"), - "stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"), - "stage_icon_status_running" => custom_icon("icon_status_running_borderless"), - "stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"), - "stage_icon_status_created" => custom_icon("icon_status_created_borderless"), - "stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"), - "stage_icon_status_success" => custom_icon("icon_status_success_borderless"), - "stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"), - "stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"), - "icon_play" => custom_icon("icon_play"), - "icon_timer" => custom_icon("icon_timer"), - "icon_status_manual" => custom_icon("icon_status_manual"), - } } + .pipeline-svgs{ "data" => {"commit_icon_svg" => custom_icon("icon_commit"), + "icon_status_canceled" => custom_icon("icon_status_canceled"), + "icon_status_running" => custom_icon("icon_status_running"), + "icon_status_skipped" => custom_icon("icon_status_skipped"), + "icon_status_created" => custom_icon("icon_status_created"), + "icon_status_pending" => custom_icon("icon_status_pending"), + "icon_status_success" => custom_icon("icon_status_success"), + "icon_status_failed" => custom_icon("icon_status_failed"), + "icon_status_warning" => custom_icon("icon_status_warning"), + "stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"), + "stage_icon_status_running" => custom_icon("icon_status_running_borderless"), + "stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"), + "stage_icon_status_created" => custom_icon("icon_status_created_borderless"), + "stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"), + "stage_icon_status_success" => custom_icon("icon_status_success_borderless"), + "stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"), + "stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"), + "icon_play" => custom_icon("icon_play"), + "icon_timer" => custom_icon("icon_timer"), + "icon_status_manual" => custom_icon("icon_status_manual"), + } } .vue-pipelines-index diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index 18328c67f02..8024fb8979d 100644 --- a/app/views/projects/pipelines_settings/show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -1,9 +1,7 @@ -- page_title "CI/CD Pipelines" - .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 - = page_title + CI/CD Pipelines .col-lg-9 = form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f| %fieldset.builds-feature @@ -95,4 +93,4 @@ %hr .row.prepend-top-default - = render partial: 'badge', collection: @badges + = render partial: 'projects/pipelines_settings/badge', collection: @badges diff --git a/app/views/projects/runners/index.html.haml b/app/views/projects/runners/_index.html.haml index d6f691d9c24..f9808f7c990 100644 --- a/app/views/projects/runners/index.html.haml +++ b/app/views/projects/runners/_index.html.haml @@ -1,5 +1,3 @@ -- page_title "Runners" - .light.prepend-top-default %p A 'Runner' is a process which runs a job. @@ -22,6 +20,6 @@ %p.lead To start serving your jobs you can either add specific Runners to your project or use shared Runners .row .col-sm-6 - = render 'specific_runners' + = render 'projects/runners/specific_runners' .col-sm-6 - = render 'shared_runners' + = render 'projects/runners/shared_runners' diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index 5afa193357e..0671dd66e78 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -22,7 +22,7 @@ - else %h4.underlined-title Available shared Runners : #{@shared_runners_count} %ul.bordered-list.available-shared-runners - = render partial: 'runner', collection: @shared_runners, as: :runner + = render partial: 'projects/runners/runner', collection: @shared_runners, as: :runner - if @shared_runners_count > 10 .light and #{@shared_runners_count - 10} more... diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index dcff675eafc..6b8e6bd4fee 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -20,10 +20,10 @@ - if @project_runners.any? %h4.underlined-title Runners activated for this project %ul.bordered-list.activated-specific-runners - = render partial: 'runner', collection: @project_runners, as: :runner + = render partial: 'projects/runners/runner', collection: @project_runners, as: :runner - if @assignable_runners.any? %h4.underlined-title Available specific runners %ul.bordered-list.available-specific-runners - = render partial: 'runner', collection: @assignable_runners, as: :runner + = render partial: 'projects/runners/runner', collection: @assignable_runners, as: :runner = paginate @assignable_runners, theme: "gitlab" diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 8ca4c51a064..3a323d94cc2 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -1,16 +1,19 @@ -- run_actions_text = "Perform common operations on this project: #{@project.name_with_namespace}" +- run_actions_text = "Perform common operations on GitLab project: #{@project.name_with_namespace}" -To setup this service: -%ul.list-unstyled +%p To setup this service: +%ul.list-unstyled.indent-list %li 1. - = link_to 'Enable custom slash commands', 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands' + = link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do + Enable custom slash commands + = icon('external-link') on your Mattermost installation %li 2. - = link_to 'Add a slash command', 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command' - in Mattermost with these options: - + = link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noreferrer noopener nofollow' do + Add a slash command + = icon('external-link') + in your Mattermost team with these options: %hr .help-form @@ -83,9 +86,14 @@ To setup this service: %hr -%ul.list-unstyled +%ul.list-unstyled.indent-list %li - 3. After adding the slash command, paste the - - %strong token + 3. Paste the + %strong Token into the field below + %li + 4. Select the + %strong Active + checkbox, press + %strong Save changes + and start using GitLab inside Mattermost! diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index c1e576b42fc..a04fd5035a6 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -1,13 +1,16 @@ - enabled = Gitlab.config.mattermost.enabled .well - This service allows GitLab users to perform common operations on this - project by entering slash commands in Mattermost. - %br - See list of available commands in Mattermost after setting up this service, - by entering - %code /<command_trigger_word> help - + %p + This service allows users to perform common operations on this + project by entering slash commands in Mattermost. + = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank', ref: 'noreferrer nofollow noopener' do + View documentation + = icon('external-link') + %p.inline + See list of available commands in Mattermost after setting up this service, + by entering + %kbd.inline /<trigger> help - unless enabled || @service.template? = render 'projects/services/mattermost_slash_commands/detailed_help', subject: @service diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 04b9100acc6..0d973a20d4c 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -1,21 +1,25 @@ -- pretty_name = defined?(@project) ? @project.name_with_namespace : "namespace / path" -- run_actions_text = "Perform common operations on this project: #{pretty_name}" +- pretty_name = defined?(@project) ? @project.name_with_namespace : 'namespace / path' +- run_actions_text = "Perform common operations on GitLab project: #{pretty_name}" .well - This service allows GitLab users to perform common operations on this - project by entering slash commands in Slack. - %br - See list of available commands in Slack after setting up this service, - by entering - %code /<command> help - %br - %br + %p + This service allows users to perform common operations on this + project by entering slash commands in Slack. + = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank', ref: 'noreferrer nofollow noopener' do + View documentation + = icon('external-link') + %p.inline + See list of available commands in Slack after setting up this service, + by entering + %kbd.inline /<command> help - unless @service.template? - To setup this service: - %ul.list-unstyled + %p To setup this service: + %ul.list-unstyled.indent-list %li 1. - = link_to 'Add a slash command', 'https://my.slack.com/services/new/slash-commands' + = link_to 'https://my.slack.com/services/new/slash-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do + Add a slash command + = icon('external-link') in your Slack team with these options: %hr @@ -82,7 +86,7 @@ %hr - %ul.list-unstyled + %ul.list-unstyled.indent-list %li 2. Paste the %strong Token diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml new file mode 100644 index 00000000000..52f5f7b81e2 --- /dev/null +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -0,0 +1,6 @@ +- page_title "CI/CD Pipelines" + += render 'projects/runners/index' += render 'projects/variables/index' += render 'projects/triggers/index' += render 'projects/pipelines_settings/show' diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 485b23815bc..6b3d7d4008b 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -4,7 +4,7 @@ .project-snippets %article.file-holder.snippet-file-content - .file-title + .js-file-title.file-title = blob_icon 0, @snippet.file_name = @snippet.file_name .file-actions diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index a1f4e3e8ed6..bdcc160a067 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,5 +1,5 @@ %article.file-holder.readme-holder - .file-title + .js-file-title.file-title = blob_icon readme.mode, readme.name = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, @path, readme.name)) do %strong diff --git a/app/views/projects/triggers/index.html.haml b/app/views/projects/triggers/_index.html.haml index b9c4e323430..33883facf9b 100644 --- a/app/views/projects/triggers/index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,9 +1,7 @@ -- page_title "Triggers" - .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 - = page_title + Triggers %p.prepend-top-20 Triggers can force a specific branch or tag to get rebuilt with an API call. %p.append-bottom-0 @@ -25,12 +23,12 @@ %th %strong Last used %th - = render partial: 'trigger', collection: @triggers, as: :trigger + = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - else %p.settings-message.text-center.append-bottom-default No triggers have been created yet. Add one using the button below. - = form_for @trigger, url: url_for(controller: 'projects/triggers', action: 'create') do |f| + = form_for @trigger, url: url_for(controller: '/projects/triggers', action: 'create') do |f| = f.submit "Add trigger", class: 'btn btn-success' .panel-footer @@ -67,7 +65,7 @@ In the %code .gitlab-ci.yml of another project, include the following snippet. - The project will be rebuilt at the end of the job. + The project will be rebuilt at the end of the pipeline. %pre :plain @@ -91,7 +89,7 @@ %p.light Add %code variables[VARIABLE]=VALUE - to an API request. Variable values can be used to distinguish between triggered jobs and normal jobs. + to an API request. Variable values can be used to distinguish between triggered pipelines and normal pipelines. With cURL: diff --git a/app/views/projects/variables/index.html.haml b/app/views/projects/variables/_index.html.haml index cf7ae0b489f..1b852a9c5b3 100644 --- a/app/views/projects/variables/index.html.haml +++ b/app/views/projects/variables/_index.html.haml @@ -1,12 +1,10 @@ -- page_title "Variables" - .row.prepend-top-default.append-bottom-default .col-lg-3 - = render "content" + = render "projects/variables/content" .col-lg-9 %h5.prepend-top-0 Add a variable - = render "form", btn_text: "Add new variable" + = render "projects/variables/form", btn_text: "Add new variable" %hr %h5.prepend-top-0 Your variables (#{@project.variables.size}) @@ -14,5 +12,5 @@ %p.settings-message.text-center.append-bottom-0 No variables found, add one with the form above. - else - = render "table" + = render "projects/variables/table" %button.btn.btn-info.js-btn-toggle-reveal-values{ "data-status" => 'hidden' } Reveal Values diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml index c74f53b4c39..3d33679f07d 100644 --- a/app/views/projects/wikis/_new.html.haml +++ b/app/views/projects/wikis/_new.html.haml @@ -13,5 +13,9 @@ = label_tag :new_wiki_path do %span Page slug = text_field_tag :new_wiki_path, nil, placeholder: 'how-to-setup', class: 'form-control', required: true, :'data-wikis-path' => namespace_project_wikis_path(@project.namespace, @project), autofocus: true + %span.new-wiki-page-slug-tip + = icon('lightbulb-o') + Tip: You can specify the full path for the new file. + We will automatically create any missing directories. .form-actions = button_tag 'Create Page', class: 'build-new-wiki btn btn-create' diff --git a/app/views/projects/wikis/_pages_wiki_page.html.haml b/app/views/projects/wikis/_pages_wiki_page.html.haml new file mode 100644 index 00000000000..6298cf6c8da --- /dev/null +++ b/app/views/projects/wikis/_pages_wiki_page.html.haml @@ -0,0 +1,5 @@ +%li + = link_to wiki_page.title, namespace_project_wiki_path(@project.namespace, @project, wiki_page) + %small (#{wiki_page.format}) + .pull-right + %small Last edited #{time_ago_with_tooltip(wiki_page.commit.authored_date)} diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index cad9c15a49e..8c582f747b3 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -1,4 +1,4 @@ -%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar +%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } .block.wiki-sidebar-header.append-bottom-default %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-wiki-toggle{ href: "#" } = icon('angle-double-right') @@ -12,10 +12,8 @@ .blocks-container .block.block-first %ul.wiki-pages - - @sidebar_wiki_pages.each do |wiki_page| - %li{ class: params[:id] == wiki_page.slug ? 'active' : '' } - = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_page) do - = wiki_page.title.capitalize + = render @sidebar_wiki_entries, context: 'sidebar' + .block = link_to namespace_project_wikis_pages_path(@project.namespace, @project), class: 'btn btn-block' do More Pages diff --git a/app/views/projects/wikis/_sidebar_wiki_page.html.haml b/app/views/projects/wikis/_sidebar_wiki_page.html.haml new file mode 100644 index 00000000000..eb9bd14920d --- /dev/null +++ b/app/views/projects/wikis/_sidebar_wiki_page.html.haml @@ -0,0 +1,3 @@ +%li{ class: params[:id] == wiki_page.slug ? 'active' : '' } + = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_page) do + = wiki_page.title.capitalize diff --git a/app/views/projects/wikis/_wiki_directory.html.haml b/app/views/projects/wikis/_wiki_directory.html.haml new file mode 100644 index 00000000000..0e5f32ed859 --- /dev/null +++ b/app/views/projects/wikis/_wiki_directory.html.haml @@ -0,0 +1,4 @@ +%li + = wiki_directory.slug + %ul + = render wiki_directory.pages, context: context diff --git a/app/views/projects/wikis/_wiki_page.html.haml b/app/views/projects/wikis/_wiki_page.html.haml new file mode 100644 index 00000000000..c84d06dad02 --- /dev/null +++ b/app/views/projects/wikis/_wiki_page.html.haml @@ -0,0 +1 @@ += render "#{context}_wiki_page", wiki_page: wiki_page diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml index e1eaffc6884..5fba2b1a5ae 100644 --- a/app/views/projects/wikis/pages.html.haml +++ b/app/views/projects/wikis/pages.html.haml @@ -13,11 +13,7 @@ = icon('cloud-download') Clone repository - %ul.content-list - - @wiki_pages.each do |wiki_page| - %li - = link_to wiki_page.title, namespace_project_wiki_path(@project.namespace, @project, wiki_page) - %small (#{wiki_page.format}) - .pull-right - %small Last edited #{time_ago_with_tooltip(wiki_page.commit.authored_date)} + %ul.wiki-pages-list.content-list + = render @wiki_entries, context: 'pages' + = paginate @wiki_pages, theme: 'gitlab' diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 1b6dceee241..3609461b721 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -6,9 +6,11 @@ %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') + .wiki-breadcrumb + %span= breadcrumb(@page.slug) + .nav-text %h2.wiki-page-title= @page.title.capitalize - %span.wiki-last-edit-by Last edited by %strong diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index 9e8adc82583..7f1f807e2e7 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -1,7 +1,7 @@ - file_name, blob = blob .blob-result .file-holder - .file-title + .js-file-title.file-title - ref = @search_results.repository_ref - blob_link = namespace_project_blob_path(@project.namespace, @project, tree_join(ref, file_name)) = link_to blob_link do diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index 23ca6479414..f7808ea6aff 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -14,7 +14,7 @@ - snippet_path = reliable_snippet_path(snippet) = link_to snippet_path do .file-holder - .file-title + .js-file-title.file-title %i.fa.fa-file %strong= snippet.file_name - if markup?(snippet.file_name) diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index 648d0bd76cb..d87f9df2677 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -1,7 +1,7 @@ - wiki_blob = parse_search_result(wiki_blob) .blob-result .file-holder - .file-title + .js-file-title.file-title = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_blob.basename) do %i.fa.fa-file %strong diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index c196bc06b17..4b98ff88241 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -17,9 +17,9 @@ Try to keep the first line under 52 characters and the others under 72. - if descriptions.present? - %p.hint.js-with-description-hint + .hint.js-with-description-hint = link_to "#", class: "js-with-description-link" do Include description in commit message - %p.hint.js-without-description-hint.hide + .hint.js-without-description-hint.hide = link_to "#", class: "js-without-description-link" do Don't include description in commit message diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 0bc851b4256..efb207b9916 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -1,3 +1,4 @@ +- parent = Group.find_by(id: params[:parent_id] || @group.parent_id) - if @group.persisted? .form-group = f.label :name, class: 'control-label' do @@ -11,11 +12,15 @@ .col-sm-10 .input-group.gl-field-error-anchor .input-group-addon - = root_url + %span>= root_url + - if parent + %strong= parent.full_path + '/' = f.text_field :path, placeholder: 'open-source', class: 'form-control', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_SIMPLE, title: 'Please choose a group name with no special characters.' + - if parent + = f.hidden_field :parent_id, value: parent.id - if @group.persisted? .alert.alert-warning.prepend-top-10 diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml new file mode 100644 index 00000000000..1264e524d86 --- /dev/null +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -0,0 +1,19 @@ +- note_count = @issuable_meta_data[issuable.id].notes_count +- issue_votes = @issuable_meta_data[issuable.id] +- upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes +- issuable_url = @collection_type == "Issue" ? issue_path(issuable, anchor: 'notes') : merge_request_path(issuable, anchor: 'notes') + +- if upvotes > 0 + %li + = icon('thumbs-up') + = upvotes + +- if downvotes > 0 + %li + = icon('thumbs-down') + = downvotes + +%li + = link_to issuable_url, class: ('no-comments' if note_count.zero?) do + = icon('comments') + = note_count diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml new file mode 100644 index 00000000000..b0778653d4e --- /dev/null +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -0,0 +1,18 @@ +.stage-cell + - pipeline.stages.each do |stage| + - if stage.status + - detailed_status = stage.detailed_status(current_user) + - icon_status = "#{detailed_status.icon}_borderless" + - status_klass = "ci-status-icon ci-status-icon-#{detailed_status.group}" + + .stage-container.dropdown{ class: klass } + %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: stage.name) } } + = custom_icon(icon_status) + = icon('caret-down') + + %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container + .arrow-up + .js-builds-dropdown-list.scrollable-menu + + .js-builds-dropdown-loading.builds-dropdown-loading.hidden + %span.fa.fa-spinner.fa-spin diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml index b11257ee0e6..73efec88bb1 100644 --- a/app/views/shared/_visibility_level.html.haml +++ b/app/views/shared/_visibility_level.html.haml @@ -1,8 +1,11 @@ +- with_label = local_assigns.fetch(:with_label, true) + .form-group.project-visibility-level-holder - = f.label :visibility_level, class: 'control-label' do - Visibility Level - = link_to icon('question-circle'), help_page_path("public_access/public_access") - .col-sm-10 + - if with_label + = f.label :visibility_level, class: 'control-label' do + Visibility Level + = link_to icon('question-circle'), help_page_path("public_access/public_access") + %div{ :class => ("col-sm-10" if with_label) } - if can_change_visibility_level = render('shared/visibility_radios', model_method: :visibility_level, form: f, selected_level: visibility_level, form_model: form_model) - else diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index dd9e433491b..60ca23ef680 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -1,4 +1,5 @@ - group_member = local_assigns[:group_member] +- full_name = true unless local_assigns[:full_name] == false - css_class = '' unless local_assigns[:css_class] - css_class += " no-description" if group.description.blank? @@ -28,7 +29,10 @@ = image_tag group_icon(group), class: "avatar s40 hidden-xs" .title = link_to group, class: 'group-name' do - = group.full_name + - if full_name + = group.full_name + - else + = 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 2ad06dcf25b..f17ae9f28eb 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -54,7 +54,7 @@ .issues_bulk_update.hide = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do + = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do %ul %li %a{ href: "#", data: { id: "reopen" } } Open @@ -62,13 +62,13 @@ %a{ href: "#", data: {id: "close" } } Closed .filter-item.inline = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) .filter-item.inline - = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } .filter-item.inline - = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do + = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do %ul %li %a{ href: "#", data: { id: "subscribe" } } Subscribe diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 55360dadbc4..6e417aa2251 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -11,7 +11,7 @@ class: "check_all_issues left" .issues-other-filters.filtered-search-container .filtered-search-input-container - %input.form-control.filtered-search{ placeholder: 'Search or filter results...', 'data-id' => 'filtered-search', 'data-project-id' => @project.id, 'data-username-params' => @users.to_json(only: [:id, :username]) } + %input.form-control.filtered-search{ placeholder: 'Search or filter results...', 'data-id' => 'filtered-search', 'data-project-id' => @project.id, 'data-username-params' => @users.to_json(only: [:id, :username]), 'data-base-endpoint' => namespace_project_path(@project.namespace, @project) } = icon('filter') %button.clear-search.hidden{ type: 'button' } = icon('times') @@ -101,7 +101,7 @@ .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } .filter-item.inline = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do %ul diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 77fc44fa5cc..3f7f1a86b9f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -2,7 +2,7 @@ - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('issuable') -%aside.right-sidebar{ class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } +%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 659d4c905fc..239387fc9fa 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -19,9 +19,9 @@ %label.label.label-danger %strong Blocked - - if source.instance_of?(Group) && !@group + - if source.instance_of?(Group) && source != @group · - = link_to source.name, source, class: "member-group-link" + = link_to source.full_name, source, class: "member-group-link" .hidden-xs.cgray - if member.request? @@ -44,8 +44,9 @@ = link_to member.created_by.name, user_path(member.created_by) = time_ago_with_tooltip(member.created_at) - if show_roles + - current_resource = @project || @group .controls.member-controls - - if show_controls && (member.respond_to?(:group) && @group) || (member.respond_to?(:project) && @project) + - if show_controls && member.source == current_resource - if user != current_user = form_for member, remote: true, html: { class: 'form-horizontal js-edit-member-form' } do |f| = f.hidden_field :access_level diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 748b10a1298..ed94773ef89 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -10,6 +10,3 @@ .col-sm-10 = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date" %a.inline.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date - -:javascript - new gl.DueDateSelectors(); diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 28935c8b598..4c7d69d40d5 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -5,7 +5,7 @@ - base_url_args = [project.namespace.becomes(Namespace), project, issuable_type] - can_update = can?(current_user, :"update_#{issuable.to_ability_name}", issuable) -%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'ui-sort-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-url' => polymorphic_path(issuable) } +%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'is-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-id' => issuable.id, 'data-url' => polymorphic_path(issuable) } %span - if show_project_name %strong #{project.name} · diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index ac028f18e50..c19697802ce 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,6 +1,7 @@ - @sort ||= sort_value_recently_updated - personal = params[:personal] - archived = params[:archived] +- shared = params[:shared] - namespace_id = params[:namespace_id] .dropdown - toggle_text = projects_sort_options_hash[@sort] @@ -28,3 +29,14 @@ %li = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, personal: true), class: ("is-active" if personal.present?) do Owned by me + - if @group && @group.shared_projects.present? + %li.divider + %li + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: nil), class: ("is-active" unless shared.present?) do + All projects + %li + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 0), class: ("is-active" if shared == '0') do + Hide shared projects + %li + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 1), class: ("is-active" if shared == '1') do + Hide group projects diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 56c0f7390a5..e7f7db73223 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -18,7 +18,7 @@ = f.label :file_name, "File", class: 'control-label' .col-sm-10 .file-holder.snippet - .file-title + .js-file-title.file-title = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name' .file-content.code %pre#editor= @snippet.content diff --git a/app/views/sherlock/file_samples/show.html.haml b/app/views/sherlock/file_samples/show.html.haml index 92151176fce..1a6e2542dc1 100644 --- a/app/views/sherlock/file_samples/show.html.haml +++ b/app/views/sherlock/file_samples/show.html.haml @@ -26,7 +26,7 @@ = @file_sample.events %article.file-holder - .file-title + .js-file-title.file-title %i.fa.fa-file-text-o.fa-fw %strong = @file_sample.file diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 837a1a0cc8c..970afbe6b64 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -3,7 +3,7 @@ = render 'shared/snippets/header' %article.file-holder.snippet-file-content - .file-title + .js-file-title.file-title = blob_icon 0, @snippet.file_name = @snippet.file_name .file-actions diff --git a/app/views/users/calendar.html.haml b/app/views/users/calendar.html.haml index 6228245d8d0..57b8845c55d 100644 --- a/app/views/users/calendar.html.haml +++ b/app/views/users/calendar.html.haml @@ -1,7 +1,7 @@ .clearfix.calendar .js-contrib-calendar .calendar-hint - Summary of issues, merge requests, and push events + Summary of issues, merge requests, push events, and comments :javascript new Calendar( #{@activity_dates.to_json}, diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index b09782749f5..4afd31f788b 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -10,11 +10,17 @@ %i.fa.fa-clock-o = event.created_at.to_s(:time) - if event.push? - #{event.action_name} #{event.ref_type} #{event.ref_name} + #{event.action_name} #{event.ref_type} + %strong + - commits_path = namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) + = link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path - else = event_action_name(event) - - if event.target - %strong= link_to "#{event.target.to_reference}", [event.project.namespace.becomes(Namespace), event.project, event.target] + %strong + - if event.note? + = link_to event.note_target.to_reference, event_note_target_path(event) + - elsif event.target + = link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target] at %strong diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 44254040e4e..dc2fea450bd 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -106,6 +106,8 @@ %i.fa.fa-spinner.fa-spin .user-calendar-activities + %h4.prepend-top-20 + Most Recent Activity .content_list{ data: { href: user_path } } = spinner diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 6abbb5a5250..0e20df506a3 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -16,6 +16,6 @@ class AuthorizedProjectsWorker def perform(user_id) user = User.find_by(id: user_id) - user.refresh_authorized_projects if user + user&.refresh_authorized_projects end end diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb index 3194c389b3d..5483bbb210b 100644 --- a/app/workers/delete_user_worker.rb +++ b/app/workers/delete_user_worker.rb @@ -6,6 +6,6 @@ class DeleteUserWorker delete_user = User.find(delete_user_id) current_user = User.find(current_user_id) - DeleteUserService.new(current_user).execute(delete_user, options.symbolize_keys) + Users::DestroyService.new(current_user).execute(delete_user, options.symbolize_keys) end end diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index a49a5fd0855..07e82767b06 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -11,6 +11,6 @@ class GroupDestroyWorker user = User.find(user_id) - DestroyGroupService.new(group, user).execute + Groups::DestroyService.new(group, user).execute end end |