diff options
author | Lin Jen-Shin <godfat@godfat.org> | 2017-02-15 16:13:14 +0800 |
---|---|---|
committer | Lin Jen-Shin <godfat@godfat.org> | 2017-02-15 16:13:14 +0800 |
commit | a065ee341c599f9500cd0b52a873cdb71a0bce55 (patch) | |
tree | 28277b71118c7f7385add7c8ae4493f1d964305b /app | |
parent | 188c231304845ff29506dc152aaea6ec42373015 (diff) | |
parent | 1452729304393978ec93b712130dff6687db01b9 (diff) | |
download | gitlab-ce-a065ee341c599f9500cd0b52a873cdb71a0bce55.tar.gz |
Merge remote-tracking branch 'upstream/master' into use-update-runner-service
* upstream/master: (488 commits)
Remove duplicate CHANGELOG.md entries for 8.16.5
Update CHANGELOG.md for 8.14.9
Update CHANGELOG.md for 8.15.6
#27631: Add missing top-area div to activity header page
Update CHANGELOG.md for 8.16.5
Update CHANGELOG.md for 8.16.5
Update CHANGELOG.md for 8.16.5
Fix yarn lock and package.json mismatch caused by MR 9133
sync yarn.lock with recent changes to package.json
Add changelog
Fix z index bugs
Add Links to Branches in Calendar Activity
SidekiqStatus need to be qualified in some cases
Replace static fixture for behaviors/requires_input_spec.js (!9162)
API: Consolidate /projects endpoint
Add MySQL info in install requirements
Fix timezone on issue boards due date
Use Gitlab::Database.with_connection_pool from !9192
Disconnect the pool after done
Use threads directly, introduce pool later:
...
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 |