diff options
153 files changed, 1604 insertions, 834 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 315cd1e598c..7c08c29d8a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -430,7 +430,7 @@ merge request: 1. [Newlines styleguide][newlines-styleguide] 1. [Testing](doc/development/testing.md) 1. [JavaScript (ES6)](https://github.com/airbnb/javascript) -1. [JavaScript (ES5)](https://github.com/airbnb/javascript/tree/master/es5) +1. [JavaScript (ES5)](https://github.com/airbnb/javascript/tree/es5-deprecated/es5) 1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab contributors to enhance security diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION index abd410582de..0d91a54c7d4 100644 --- a/GITLAB_PAGES_VERSION +++ b/GITLAB_PAGES_VERSION @@ -1 +1 @@ -0.2.4 +0.3.0 diff --git a/PROCESS.md b/PROCESS.md index 6eabaf05d24..f257c1d5358 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -59,20 +59,38 @@ star, smile, etc.). Some good tips about code reviews can be found in our ## Feature Freeze -On the 7th of each month, the stable branches for the upcoming release will -be frozen for major changes. Merge requests may still be merged into master -during this period. By freezing the stable branches prior to a release there's -no need to worry about last minute merge requests potentially breaking a lot of -things. +On the 7th of each month, RC1 of the upcoming release is created and deployed to GitLab.com and the stable branch for this release is frozen, which means master is no longer merged into it. +Merge requests may still be merged into master during this period, +but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch. +By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things. -What is considered to be a major change is determined on a case by case basis as -this definition depends very much on the context of changes. For example, a 5 -line change might have a big impact on the entire application. Ultimately the -decision will be made by the maintainers and the release managers. +Once the stable branch is frozen, only fixes for regressions (bugs introduced in that same release) +and security issues will be cherry-picked into the stable branch. +Any merge requests cherry-picked into the stable branch for a previous release will also be picked into the latest stable branch. +These fixes will be released in the next RC (before the 22nd) or patch release (after the 22nd). + +If you think a merge request should go into the upcoming release even though it does not meet these requirements, +you can ask for an exception to be made. Exceptions require sign-off from 3 people besides the developer: + +1. a Release Manager +2. an Engineering Lead +3. an Engineering Director, the VP of Engineering, or the CTO + +You can find who is who on the [team page](https://about.gitlab.com/team/). + +Whether an exception is made is determined by weighing the benefit and urgency of the change +(how important it is to the company that this is released _right now_ instead of in a month) +against the potential negative impact +(things breaking without enough time to comfortably find and fix them before the release on the 22nd). +When in doubt, we err on the side of _not_ cherry-picking. + +For example, it is likely that an exception will be made for a trivial 1-5 line performance improvement +(e.g. adding a database index or adding `includes` to a query), but not for a new feature, no matter how relatively small or thoroughly tested. During the feature freeze all merge requests that are meant to go into the upcoming release should have the correct milestone assigned _and_ have the label -~"Pick into Stable" set. Merge requests without a milestone and this label will +~"Pick into Stable" set, so that release managers can find and pick them. +Merge requests without a milestone and this label will not be merged into any stable branches. ## Copy & paste responses diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index ea3f13bd00f..4ecbf195b64 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'); @@ -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'); diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 index c345fb6ce14..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)$/)); 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..ac2966cef5d 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'); }); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 index 11a3449d99a..f1b41911b73 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 +++ b/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 @@ -26,7 +26,7 @@ class PipelinesStore { */ startTimeAgoLoops() { const startTimeLoops = () => { - this.timeLoopInterval = setInterval(function timeloopInterval() { + 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); diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index f8efca76b13..7eec2d39a9c 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -97,6 +97,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); break; case 'projects:milestones:new': case 'projects:milestones:edit': + case 'projects:milestones:update': new ZenMode(); new gl.DueDateSelectors(); new gl.GLForm($('.milestone-form')); 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/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..77fa662892a 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -437,7 +437,7 @@ } }; - GitLabDropdown.prototype.opened = function() { + GitLabDropdown.prototype.opened = function(e) { var contentHtml; this.resetRows(); this.addArrowKeyEvent(); @@ -457,6 +457,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/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/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 4becbc32681..919fcd0a07b 100644 --- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js.es6 +++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js.es6 @@ -28,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/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/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_shared/components/pipelines_table_row.js.es6 b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 index c819f0dd7cd..61c1b72d9d2 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 @@ -111,7 +111,7 @@ require('./commit'); * If provided, returns the commit ref. * Needed to render the commit component column. * - * Matched `url` prop sent in the API to `path` prop needed + * Matches `path` prop sent in the API to `ref_url` prop needed * in the commit component. * * @returns {Object|Undefined} @@ -119,8 +119,8 @@ require('./commit'); commitRef() { if (this.pipeline.ref) { return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { - if (prop === 'url') { - accumulator.path = this.pipeline.ref[prop]; + if (prop === 'path') { + accumulator.ref_url = this.pipeline.ref[prop]; } else { accumulator[prop] = this.pipeline.ref[prop]; } 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/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 1d2d1bfc0d7..d485e75a434 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -43,3 +43,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/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ca5861bf3e6..facfb7f9920 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -502,119 +502,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/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/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 762b95a657c..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; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index b01d8d695d6..8541fe75e8d 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -125,6 +125,12 @@ 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; 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/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/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/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/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/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/registrations_controller.rb b/app/controllers/registrations_controller.rb index 68bf01ba08d..b44f38d4a0c 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -24,7 +24,7 @@ class RegistrationsController < Devise::RegistrationsController 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 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/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 91b24b8bc29..b5f8c23a667 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -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/todos_helper.rb b/app/helpers/todos_helper.rb index c568cca9e5e..d7d51c99979 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -86,7 +86,9 @@ 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' } ] end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 5213ea9d02b..8c1b076c2d7 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -20,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 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/merge_request.rb b/app/models/merge_request.rb index 43085f69105..c0d4dd0197f 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,10 +558,6 @@ 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 @@ -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.issues - closes_issues + ext.issues - closes_issues(current_user) end def target_project_path diff --git a/app/models/namespace.rb b/app/models/namespace.rb index c5713fb7818..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? @@ -217,6 +207,10 @@ class Namespace < ActiveRecord::Base [owner_id] end + def parent_changed? + parent_id_changed? + end + private def repository_storage_paths @@ -255,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 @@ -274,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/project.rb b/app/models/project.rb index b45f22d94d9..c17bcedf7b2 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) { @@ -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) @@ -1328,6 +1313,18 @@ class Project < ActiveRecord::Base map.public_path_for_source_path(path) end + def parent + namespace + 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) @@ -1366,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/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/user.rb b/app/models/user.rb index f64d0c17a45..33666b4f35b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -119,7 +119,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? } 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 173fa922f56..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') 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/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 diff --git a/changelogs/unreleased/20495-plus-icon-button.yml b/changelogs/unreleased/20495-plus-icon-button.yml new file mode 100644 index 00000000000..0f8650eb7b6 --- /dev/null +++ b/changelogs/unreleased/20495-plus-icon-button.yml @@ -0,0 +1,4 @@ +--- +title: Remove plus icon from MR button on compare view +merge_request: +author: diff --git a/changelogs/unreleased/23104-remove-public-param-for-projects.yml b/changelogs/unreleased/23104-remove-public-param-for-projects.yml new file mode 100644 index 00000000000..78eb785279f --- /dev/null +++ b/changelogs/unreleased/23104-remove-public-param-for-projects.yml @@ -0,0 +1,4 @@ +--- +title: 'API: remove `public` param for projects' +merge_request: 8736 +author: diff --git a/changelogs/unreleased/26705-filter-todos-by-manual-add.yml b/changelogs/unreleased/26705-filter-todos-by-manual-add.yml new file mode 100644 index 00000000000..3521496a20e --- /dev/null +++ b/changelogs/unreleased/26705-filter-todos-by-manual-add.yml @@ -0,0 +1,4 @@ +--- +title: Filter todos by manual add +merge_request: 8691 +author: Jacopo Beschi @jacopo-beschi diff --git a/changelogs/unreleased/27880-pipelines-table-not-showing-commit-branch.yml b/changelogs/unreleased/27880-pipelines-table-not-showing-commit-branch.yml new file mode 100644 index 00000000000..4251754618b --- /dev/null +++ b/changelogs/unreleased/27880-pipelines-table-not-showing-commit-branch.yml @@ -0,0 +1,4 @@ +--- +title: Fixes Pipelines table is not showing branch name for commit +merge_request: +author: diff --git a/changelogs/unreleased/bypass-email-domain-validation-when-created-by-admin.yml b/changelogs/unreleased/bypass-email-domain-validation-when-created-by-admin.yml new file mode 100644 index 00000000000..f335ae27fda --- /dev/null +++ b/changelogs/unreleased/bypass-email-domain-validation-when-created-by-admin.yml @@ -0,0 +1,4 @@ +--- +title: Bypass email domain validation when a user is created by an admin. +merge_request: 8575 +author: Reza Mohammadi @remohammadi diff --git a/changelogs/unreleased/dz-create-nested-groups-via-ui.yml b/changelogs/unreleased/dz-create-nested-groups-via-ui.yml new file mode 100644 index 00000000000..f9529a5941a --- /dev/null +++ b/changelogs/unreleased/dz-create-nested-groups-via-ui.yml @@ -0,0 +1,4 @@ +--- +title: Allow creating nested groups via UI +merge_request: 8786 +author: diff --git a/changelogs/unreleased/dz-nested-groups-api.yml b/changelogs/unreleased/dz-nested-groups-api.yml new file mode 100644 index 00000000000..d33ff42700f --- /dev/null +++ b/changelogs/unreleased/dz-nested-groups-api.yml @@ -0,0 +1,4 @@ +--- +title: Add nested groups to the API +merge_request: 9034 +author: diff --git a/changelogs/unreleased/dz-refactor-full-path.yml b/changelogs/unreleased/dz-refactor-full-path.yml new file mode 100644 index 00000000000..da8568fd220 --- /dev/null +++ b/changelogs/unreleased/dz-refactor-full-path.yml @@ -0,0 +1,4 @@ +--- +title: Store group and project full name and full path in routes table +merge_request: 8979 +author: diff --git a/changelogs/unreleased/pass in current_user in MergeRequest and MergeRequestsHelper.yml b/changelogs/unreleased/pass in current_user in MergeRequest and MergeRequestsHelper.yml new file mode 100644 index 00000000000..0751047c3c0 --- /dev/null +++ b/changelogs/unreleased/pass in current_user in MergeRequest and MergeRequestsHelper.yml @@ -0,0 +1,4 @@ +--- +title: pass in current_user in MergeRequest and MergeRequestsHelper +merge_request: 8624 +author: Dongqing Hu diff --git a/changelogs/unreleased/removal_of_unused_parameter.yml b/changelogs/unreleased/removal_of_unused_parameter.yml new file mode 100644 index 00000000000..26bffafd9d9 --- /dev/null +++ b/changelogs/unreleased/removal_of_unused_parameter.yml @@ -0,0 +1,4 @@ +--- +title: 'removed unused parameter ''status_only: true''' +merge_request: +author: diff --git a/changelogs/unreleased/remove-jquery-ui-datepicker.yml b/changelogs/unreleased/remove-jquery-ui-datepicker.yml new file mode 100644 index 00000000000..cd00690d774 --- /dev/null +++ b/changelogs/unreleased/remove-jquery-ui-datepicker.yml @@ -0,0 +1,4 @@ +--- +title: Replaced jQuery UI datepicker +merge_request: +author: diff --git a/changelogs/unreleased/remove-jquery-ui-sortable.yml b/changelogs/unreleased/remove-jquery-ui-sortable.yml new file mode 100644 index 00000000000..35f47822738 --- /dev/null +++ b/changelogs/unreleased/remove-jquery-ui-sortable.yml @@ -0,0 +1,4 @@ +--- +title: Replaced jQuery UI sortable +merge_request: +author: diff --git a/changelogs/unreleased/rename_delete_services.yml b/changelogs/unreleased/rename_delete_services.yml new file mode 100644 index 00000000000..686a1ef3d55 --- /dev/null +++ b/changelogs/unreleased/rename_delete_services.yml @@ -0,0 +1,4 @@ +--- +title: Fix inconsistent naming for services that delete things +merge_request: 5803 +author: dixpac diff --git a/config/routes/group.rb b/config/routes/group.rb index 60a1175fe80..73f69d76995 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -25,5 +25,6 @@ scope(path: 'groups/*id', get :merge_requests, as: :merge_requests_group get :projects, as: :projects_group get :activity, as: :activity_group + get :subgroups, as: :subgroups_group get '/', action: :show, as: :group_canonical end diff --git a/config/webpack.config.js b/config/webpack.config.js index 953ae463c86..968c0076eaf 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -17,7 +17,7 @@ var config = { application: './application.js', blob_edit: './blob_edit/blob_edit_bundle.js', boards: './boards/boards_bundle.js', - boards_test: './boards/test_utils/simulate_drag.js', + simulate_drag: './test_utils/simulate_drag.js', cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js', diff_notes: './diff_notes/diff_notes_bundle.js', diff --git a/db/migrate/20160519203051_add_developers_can_merge_to_protected_branches.rb b/db/migrate/20160519203051_add_developers_can_merge_to_protected_branches.rb index 15ad8e8bcbb..ac50035eba4 100644 --- a/db/migrate/20160519203051_add_developers_can_merge_to_protected_branches.rb +++ b/db/migrate/20160519203051_add_developers_can_merge_to_protected_branches.rb @@ -1,9 +1,15 @@ class AddDevelopersCanMergeToProtectedBranches < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers + DOWNTIME = false + disable_ddl_transaction! - def change + def up add_column_with_default :protected_branches, :developers_can_merge, :boolean, default: false, allow_null: false end + + def down + remove_column :protected_branches, :developers_can_merge + end end diff --git a/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb index 296f1dfac7b..20a77000ba8 100644 --- a/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb +++ b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb @@ -14,7 +14,11 @@ class AddSubmittedAsHamToSpamLogs < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_column_with_default :spam_logs, :submitted_as_ham, :boolean, default: false end + + def down + remove_column :spam_logs, :submitted_as_ham + end end diff --git a/db/migrate/20170204172458_add_name_to_route.rb b/db/migrate/20170204172458_add_name_to_route.rb new file mode 100644 index 00000000000..38ed1ad9039 --- /dev/null +++ b/db/migrate/20170204172458_add_name_to_route.rb @@ -0,0 +1,12 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddNameToRoute < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :routes, :name, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 850772ba356..3fef5b82073 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1037,6 +1037,7 @@ ActiveRecord::Schema.define(version: 20170206101030) do t.string "path", null: false t.datetime "created_at" t.datetime "updated_at" + t.string "name" end add_index "routes", ["path"], name: "index_routes_on_path", unique: true, using: :btree diff --git a/doc/api/groups.md b/doc/api/groups.md index 3b38e3e1bee..84ab01c292b 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -32,7 +32,8 @@ GET /groups "web_url": "http://localhost:3000/groups/foo-bar", "request_access_enabled": false, "full_name": "Foobar Group", - "full_path": "foo-bar" + "full_path": "foo-bar", + "parent_id": null } ] ``` @@ -156,8 +157,9 @@ Example response: "avatar_url": null, "web_url": "https://gitlab.example.com/groups/twitter", "request_access_enabled": false, - "full_name": "Foobar Group", - "full_path": "foo-bar", + "full_name": "Twitter", + "full_path": "twitter", + "parent_id": null, "projects": [ { "id": 7, @@ -332,6 +334,7 @@ Parameters: - `visibility_level` (optional) - The group's visibility. 0 for private, 10 for internal, 20 for public. - `lfs_enabled` (optional) - Enable/disable Large File Storage (LFS) for the projects in this group - `request_access_enabled` (optional) - Allow users to request member access. +- `parent_id` (optional) - The parent group id for creating nested group. ## Transfer project to group @@ -383,6 +386,7 @@ Example response: "request_access_enabled": false, "full_name": "Foobar Group", "full_path": "foo-bar", + "parent_id": null, "projects": [ { "id": 9, diff --git a/doc/api/projects.md b/doc/api/projects.md index 122075bbd11..040153ac880 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -642,7 +642,6 @@ Parameters: | `snippets_enabled` | boolean | no | Enable snippets for this project | | `container_registry_enabled` | boolean | no | Enable container registry for this project | | `shared_runners_enabled` | boolean | no | Enable shared runners for this project | -| `public` | boolean | no | If `true`, the same as setting `visibility_level` to 20 | | `visibility_level` | integer | no | See [project visibility level](#project-visibility-level) | | `import_url` | string | no | URL to import repository from | | `public_builds` | boolean | no | If `true`, builds can be viewed by non-project-members | @@ -676,7 +675,6 @@ Parameters: | `snippets_enabled` | boolean | no | Enable snippets for this project | | `container_registry_enabled` | boolean | no | Enable container registry for this project | | `shared_runners_enabled` | boolean | no | Enable shared runners for this project | -| `public` | boolean | no | If `true`, the same as setting `visibility_level` to 20 | | `visibility_level` | integer | no | See [project visibility level](#project-visibility-level) | | `import_url` | string | no | URL to import repository from | | `public_builds` | boolean | no | If `true`, builds can be viewed by non-project-members | @@ -709,7 +707,6 @@ Parameters: | `snippets_enabled` | boolean | no | Enable snippets for this project | | `container_registry_enabled` | boolean | no | Enable container registry for this project | | `shared_runners_enabled` | boolean | no | Enable shared runners for this project | -| `public` | boolean | no | If `true`, the same as setting `visibility_level` to 20 | | `visibility_level` | integer | no | See [project visibility level](#project-visibility-level) | | `import_url` | string | no | URL to import repository from | | `public_builds` | boolean | no | If `true`, builds can be viewed by non-project-members | diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index 73dde599b7e..dbb3c1113e8 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -113,7 +113,7 @@ DELETE /projects/:id/repository/files ``` ```bash -curl --request PUT --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&author_email=author%40example.com&author_name=Firstname%20Lastname&commit_message=delete%20file' +curl --request DELETE --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&author_email=author%40example.com&author_name=Firstname%20Lastname&commit_message=delete%20file' ``` Example response: diff --git a/doc/integration/auth0.md b/doc/integration/auth0.md index e5247082a89..212b4854dd7 100644 --- a/doc/integration/auth0.md +++ b/doc/integration/auth0.md @@ -80,10 +80,13 @@ from step 5. 1. Change `YOUR_AUTH0_CLIENT_SECRET` to the client secret from the Auth0 Console page from step 5. -1. Save the file and [reconfigure GitLab](../administration/restart_gitlab.md) -for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. On the sign in page there should now be an Auth0 icon below the regular sign in form. Click the icon to begin the authentication process. Auth0 will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to GitLab and will be signed in. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/azure.md b/doc/integration/azure.md index 48dddf7df44..5e3e9f5ab77 100644 --- a/doc/integration/azure.md +++ b/doc/integration/azure.md @@ -78,6 +78,10 @@ To enable the Microsoft Azure OAuth2 OmniAuth provider you must register your ap 1. Save the configuration file. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a Microsoft icon below the regular sign in form. Click the icon to begin the authentication process. Microsoft will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to GitLab and will be signed in. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/cas.md b/doc/integration/cas.md index e34e306f9ac..f757edf0bc2 100644 --- a/doc/integration/cas.md +++ b/doc/integration/cas.md @@ -58,8 +58,11 @@ To enable the CAS OmniAuth provider you must register your application with your 1. Save the configuration file. -1. Run `gitlab-ctl reconfigure` for the omnibus package. - -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a CAS tab in the sign in form. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source + diff --git a/doc/integration/crowd.md b/doc/integration/crowd.md index 40d93aef2a9..f8370cd349e 100644 --- a/doc/integration/crowd.md +++ b/doc/integration/crowd.md @@ -53,6 +53,11 @@ To enable the Crowd OmniAuth provider you must register your application with Cr 1. Save the configuration file. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. + +On the sign in page there should now be a Crowd tab in the sign in form. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source -On the sign in page there should now be a Crowd tab in the sign in form.
\ No newline at end of file diff --git a/doc/integration/facebook.md b/doc/integration/facebook.md index 77bb75cbfca..a67de23b17b 100644 --- a/doc/integration/facebook.md +++ b/doc/integration/facebook.md @@ -92,6 +92,10 @@ something else descriptive. 1. Save the configuration file. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a Facebook icon below the regular sign in form. Click the icon to begin the authentication process. Facebook will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to GitLab and will be signed in. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/github.md b/doc/integration/github.md index 479c697b933..cea85f073cc 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -2,7 +2,7 @@ Import projects from GitHub and login to your GitLab instance with your GitHub account. -To enable the GitHub OmniAuth provider you must register your application with GitHub. +To enable the GitHub OmniAuth provider you must register your application with GitHub. GitHub will generate an application ID and secret key for you to use. 1. Sign in to GitHub. @@ -22,7 +22,7 @@ GitHub will generate an application ID and secret key for you to use. - Authorization callback URL is 'http(s)://${YOUR_DOMAIN}' 1. Select "Register application". -1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot). +1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot). Keep this page open as you continue configuration. ![GitHub app](img/github_app.png) @@ -49,7 +49,7 @@ GitHub will generate an application ID and secret key for you to use. For omnibus package: For GitHub.com: - + ```ruby gitlab_rails['omniauth_providers'] = [ { @@ -60,9 +60,9 @@ GitHub will generate an application ID and secret key for you to use. } ] ``` - + For GitHub Enterprise: - + ```ruby gitlab_rails['omniauth_providers'] = [ { @@ -101,10 +101,14 @@ GitHub will generate an application ID and secret key for you to use. 1. Change 'YOUR_APP_SECRET' to the client secret from the GitHub application page from step 7. -1. Save the configuration file and run `sudo gitlab-ctl reconfigure`. +1. Save the configuration file. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. -On the sign in page there should now be a GitHub icon below the regular sign in form. -Click the icon to begin the authentication process. GitHub will ask the user to sign in and authorize the GitLab application. +On the sign in page there should now be a GitHub icon below the regular sign in form. +Click the icon to begin the authentication process. GitHub will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to GitLab and will be signed in. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/gitlab.md b/doc/integration/gitlab.md index 6d8f3912ede..eec40a9b8f1 100644 --- a/doc/integration/gitlab.md +++ b/doc/integration/gitlab.md @@ -2,7 +2,7 @@ Import projects from GitLab.com and login to your GitLab instance with your GitLab.com account. -To enable the GitLab.com OmniAuth provider you must register your application with GitLab.com. +To enable the GitLab.com OmniAuth provider you must register your application with GitLab.com. GitLab.com will generate an application ID and secret key for you to use. 1. Sign in to GitLab.com @@ -26,8 +26,8 @@ GitLab.com will generate an application ID and secret key for you to use. 1. Select "Submit". -1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot). - Keep this page open as you continue configuration. +1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot). + Keep this page open as you continue configuration. ![GitLab app](img/gitlab_app.png) 1. On your GitLab server, open the configuration file. @@ -77,8 +77,12 @@ GitLab.com will generate an application ID and secret key for you to use. 1. Save the configuration file. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. -On the sign in page there should now be a GitLab.com icon below the regular sign in form. -Click the icon to begin the authentication process. GitLab.com will ask the user to sign in and authorize the GitLab application. +On the sign in page there should now be a GitLab.com icon below the regular sign in form. +Click the icon to begin the authentication process. GitLab.com will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to your GitLab instance and will be signed in. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/google.md b/doc/integration/google.md index 82978b68a34..1e7ad90c5a8 100644 --- a/doc/integration/google.md +++ b/doc/integration/google.md @@ -74,7 +74,8 @@ To enable the Google OAuth2 OmniAuth provider you must register your application 1. Save the configuration file. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a Google icon below the regular sign in form. Click the icon to begin the authentication process. Google will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to GitLab and will be signed in. @@ -87,3 +88,6 @@ At this point, when users first try to authenticate to your GitLab installation 1. Select 'Consent screen' in the left menu. (See steps 1, 4 and 5 above for instructions on how to get here if you closed your window). 1. Scroll down until you find "Product Name". Change the product name to something more descriptive. 1. Add any additional information as you wish - homepage, logo, privacy policy, etc. None of this is required, but it may help your users. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/saml.md b/doc/integration/saml.md index 4a242c321aa..7a809eddac0 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -109,7 +109,8 @@ in your SAML IdP: 1. Change the value of `issuer` to a unique name, which will identify the application to the IdP. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. 1. Register the GitLab SP in your SAML 2.0 IdP, using the application name specified in `issuer`. @@ -314,3 +315,6 @@ For this you need take the following into account: Make sure that one of the above described scenarios is valid, or the requests will fail with one of the mentioned errors. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/shibboleth.md b/doc/integration/shibboleth.md index 696c1011eeb..e0fc1bb801f 100644 --- a/doc/integration/shibboleth.md +++ b/doc/integration/shibboleth.md @@ -70,10 +70,9 @@ gitlab_rails['omniauth_providers'] = [ ] ``` -1. Save changes and reconfigure gitlab: -``` -sudo gitlab-ctl reconfigure -``` + +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a "Sign in with: Shibboleth" icon below the regular sign in form. Click the icon to begin the authentication process. You will be redirected to IdP server (Depends on your Shibboleth module configuration). If everything goes well the user will be returned to GitLab and will be signed in. @@ -122,4 +121,7 @@ you will not get a shibboleth session! RequestHeader set X_FORWARDED_PROTO 'https' RequestHeader set X-Forwarded-Ssl on -```
\ No newline at end of file +``` + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/twitter.md b/doc/integration/twitter.md index abbea09f22f..d0976b6201e 100644 --- a/doc/integration/twitter.md +++ b/doc/integration/twitter.md @@ -74,6 +74,10 @@ To enable the Twitter OmniAuth provider you must register your application with 1. Save the configuration file. -1. Restart GitLab for the changes to take effect. +1. [Reconfigure][] or [restart GitLab][] for the changes to take effect if you + installed GitLab via Omnibus or from source respectively. On the sign in page there should now be a Twitter icon below the regular sign in form. Click the icon to begin the authentication process. Twitter will ask the user to sign in and authorize the GitLab application. If everything goes well the user will be returned to GitLab and will be signed in. + +[reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure +[restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/user/admin_area/settings/sign_up_restrictions.md b/doc/user/admin_area/settings/sign_up_restrictions.md index 4b540473a6e..603b826e7f2 100644 --- a/doc/user/admin_area/settings/sign_up_restrictions.md +++ b/doc/user/admin_area/settings/sign_up_restrictions.md @@ -1,5 +1,20 @@ # Sign-up restrictions +You can block email addresses of specific domains, or whitelist only some +specifc domains via the **Application Settings** in the Admin area. + +>**Note**: These restrictions are only applied during sign-up. An admin is +able to add add a user through the admin panel with a disallowed domain. Also +note that the users can change their email addresses after signup to +disallowed domains. + +## Whitelist email domains + +> [Introduced][ce-598] in GitLab 7.11.0 + +You can restrict users to only signup using email addresses matching the given +domains list. + ## Blacklist email domains > [Introduced][ce-5259] in GitLab 8.10. @@ -9,13 +24,16 @@ from creating an account on your GitLab server. This is particularly useful to prevent spam. Disposable email addresses are usually used by malicious users to create dummy accounts and spam issues. +## Settings + This feature can be activated via the **Application Settings** in the Admin area, and you have the option of entering the list manually, or uploading a file with the list. -The blacklist accepts wildcards, so you can use `*.test.com` to block every -`test.com` subdomain, or `*.io` to block all domains ending in `.io`. Domains -should be separated by a whitespace, semicolon, comma, or a new line. +Both whitelist and blacklist accept wildcards, so for example, you can use +`*.company.com` to accept every `company.com` subdomain, or `*.io` to block all +domains ending in `.io`. Domains should be separated by a whitespace, +semicolon, comma, or a new line. ![Domain Blacklist](img/domain_blacklist.png) diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 008872b59a7..699318e2479 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -237,23 +237,24 @@ GFM will turn that reference into a link so you can navigate between them easily GFM will recognize the following: -| input | references | -|:-----------------------|:--------------------------- | -| `@user_name` | specific user | -| `@group_name` | specific group | -| `@all` | entire team | -| `#123` | issue | -| `!123` | merge request | -| `$123` | snippet | -| `~123` | label by ID | -| `~bug` | one-word label by name | -| `~"feature request"` | multi-word label by name | -| `%123` | milestone by ID | -| `%v1.23` | one-word milestone by name | -| `%"release candidate"` | multi-word milestone by name | -| `9ba12248` | specific commit | -| `9ba12248...b19a04f5` | commit range comparison | -| `[README](doc/README)` | repository file references | +| input | references | +|:---------------------------|:--------------------------------| +| `@user_name` | specific user | +| `@group_name` | specific group | +| `@all` | entire team | +| `#123` | issue | +| `!123` | merge request | +| `$123` | snippet | +| `~123` | label by ID | +| `~bug` | one-word label by name | +| `~"feature request"` | multi-word label by name | +| `%123` | milestone by ID | +| `%v1.23` | one-word milestone by name | +| `%"release candidate"` | multi-word milestone by name | +| `9ba12248` | specific commit | +| `9ba12248...b19a04f5` | commit range comparison | +| `[README](doc/README)` | repository file references | +| `[README](doc/README#L13)` | repository file line references | GFM also recognizes certain cross-project references: diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 5d7b8e021bb..3a5819d1bab 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -138,6 +138,7 @@ module API expose :web_url expose :request_access_enabled expose :full_name, :full_path + expose :parent_id expose :statistics, if: :statistics do with_options format_with: -> (value) { value.to_i } do diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 7682d286866..5c132bdd6f9 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -73,6 +73,7 @@ module API params do requires :name, type: String, desc: 'The name of the group' requires :path, type: String, desc: 'The path of the group' + optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group' use :optional_params end post do @@ -125,7 +126,7 @@ module API delete ":id" do group = find_group!(params[:id]) authorize! :admin_group, group - DestroyGroupService.new(group, current_user).execute + ::Groups::DestroyService.new(group, current_user).execute end desc 'Get a list of projects in this group.' do diff --git a/lib/api/notes.rb b/lib/api/notes.rb index 4d2a8f48267..8beccaaabd1 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -131,7 +131,7 @@ module API note = user_project.notes.find(params[:note_id]) authorize! :admin_note, note - ::Notes::DeleteService.new(user_project, current_user).execute(note) + ::Notes::DestroyService.new(user_project, current_user).execute(note) present note, with: Entities::Note end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 92a70faf1c2..bd4b23195ac 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -16,7 +16,6 @@ module API optional :shared_runners_enabled, type: Boolean, desc: 'Flag indication if shared runners are enabled for that project' optional :container_registry_enabled, type: Boolean, desc: 'Flag indication if the container registry is enabled for that project' optional :lfs_enabled, type: Boolean, desc: 'Flag indication if Git LFS is enabled for that project' - optional :public, type: Boolean, desc: 'Create a public project. The same as visibility_level = 20.' optional :visibility_level, type: Integer, values: [ Gitlab::VisibilityLevel::PRIVATE, Gitlab::VisibilityLevel::INTERNAL, @@ -26,16 +25,6 @@ module API optional :only_allow_merge_if_build_succeeds, type: Boolean, desc: 'Only allow to merge if builds succeed' optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all discussions are resolved' end - - def map_public_to_visibility_level(attrs) - publik = attrs.delete(:public) - if !publik.nil? && !attrs[:visibility_level].present? - # Since setting the public attribute to private could mean either - # private or internal, use the more conservative option, private. - attrs[:visibility_level] = (publik == true) ? Gitlab::VisibilityLevel::PUBLIC : Gitlab::VisibilityLevel::PRIVATE - end - attrs - end end resource :projects do @@ -161,7 +150,7 @@ module API use :create_params end post do - attrs = map_public_to_visibility_level(declared_params(include_missing: false)) + attrs = declared_params(include_missing: false) project = ::Projects::CreateService.new(current_user, attrs).execute if project.saved? @@ -190,7 +179,7 @@ module API user = User.find_by(id: params.delete(:user_id)) not_found!('User') unless user - attrs = map_public_to_visibility_level(declared_params(include_missing: false)) + attrs = declared_params(include_missing: false) project = ::Projects::CreateService.new(user, attrs).execute if project.saved? @@ -268,14 +257,14 @@ module API at_least_one_of :name, :description, :issues_enabled, :merge_requests_enabled, :wiki_enabled, :builds_enabled, :snippets_enabled, :shared_runners_enabled, :container_registry_enabled, - :lfs_enabled, :public, :visibility_level, :public_builds, + :lfs_enabled, :visibility_level, :public_builds, :request_access_enabled, :only_allow_merge_if_build_succeeds, :only_allow_merge_if_all_discussions_are_resolved, :path, :default_branch end put ':id' do authorize_admin_project - attrs = map_public_to_visibility_level(declared_params(include_missing: false)) + attrs = declared_params(include_missing: false) authorize! :rename_project, user_project if attrs[:name].present? authorize! :change_visibility_level, user_project if attrs[:visibility_level].present? diff --git a/lib/api/users.rb b/lib/api/users.rb index 0ed468626b7..4980a90f952 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -293,7 +293,7 @@ module API user = User.find_by(id: params[:id]) not_found!('User') unless user - DeleteUserService.new(current_user).execute(user) + ::Users::DestroyService.new(current_user).execute(user) end desc 'Block a user. Available only for admins.' diff --git a/lib/gitlab/serialize/ci/variables.rb b/lib/gitlab/serializer/ci/variables.rb index 3a9443bfcd9..c059c454eac 100644 --- a/lib/gitlab/serialize/ci/variables.rb +++ b/lib/gitlab/serializer/ci/variables.rb @@ -1,5 +1,5 @@ module Gitlab - module Serialize + module Serializer module Ci # This serializer could make sure our YAML variables' keys and values # are always strings. This is more for legacy build data because diff --git a/lib/gitlab/serializer/pagination.rb b/lib/gitlab/serializer/pagination.rb new file mode 100644 index 00000000000..bf2c0acc729 --- /dev/null +++ b/lib/gitlab/serializer/pagination.rb @@ -0,0 +1,36 @@ +module Gitlab + module Serializer + class Pagination + class InvalidResourceError < StandardError; end + include ::API::Helpers::Pagination + + def initialize(request, response) + @request = request + @response = response + end + + def paginate(resource) + if resource.respond_to?(:page) + super(resource) + else + raise InvalidResourceError + end + end + + private + + # Methods needed by `API::Helpers::Pagination` + # + + attr_reader :request + + def params + @request.query_parameters + end + + def header(header, value) + @response.headers[header] = value + end + end + end +end diff --git a/package.json b/package.json index 9581d966237..a25e09e4cf2 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "jquery-ujs": "1.2.1", "json-loader": "^0.5.4", "mousetrap": "1.4.6", + "pikaday": "^1.5.1", "select2": "3.5.2-browserify", "stats-webpack-plugin": "^0.4.2", "underscore": "1.8.3", diff --git a/rubocop/cop/migration/column_with_default.rb b/rubocop/cop/migration/add_column.rb index 97ee8b11044..1490fcdd814 100644 --- a/rubocop/cop/migration/column_with_default.rb +++ b/rubocop/cop/migration/add_column.rb @@ -1,15 +1,17 @@ +require_relative '../../migration_helpers' + module RuboCop module Cop module Migration # Cop that checks if columns are added in a way that doesn't require # downtime. - class ColumnWithDefault < RuboCop::Cop::Cop + class AddColumn < RuboCop::Cop::Cop include MigrationHelpers WHITELISTED_TABLES = [:application_settings] - MSG = 'add_column with a default value requires downtime, ' \ - 'use add_column_with_default instead' + MSG = '`add_column` with a default value requires downtime, ' \ + 'use `add_column_with_default` instead' def on_send(node) return unless in_migration?(node) diff --git a/rubocop/cop/migration/add_column_with_default.rb b/rubocop/cop/migration/add_column_with_default.rb new file mode 100644 index 00000000000..747d7caf1ef --- /dev/null +++ b/rubocop/cop/migration/add_column_with_default.rb @@ -0,0 +1,34 @@ +require_relative '../../migration_helpers' + +module RuboCop + module Cop + module Migration + # Cop that checks if `add_column_with_default` is used with `up`/`down` methods + # and not `change`. + class AddColumnWithDefault < RuboCop::Cop::Cop + include MigrationHelpers + + MSG = '`add_column_with_default` is not reversible so you must manually define ' \ + 'the `up` and `down` methods in your migration class, using `remove_column` in `down`' + + def on_send(node) + return unless in_migration?(node) + + name = node.children[1] + + return unless name == :add_column_with_default + + node.each_ancestor(:def) do |def_node| + next unless method_name(def_node) == :change + + add_offense(def_node, :name) + end + end + + def method_name(node) + node.children.first + end + end + end + end +end diff --git a/rubocop/cop/migration/add_index.rb b/rubocop/cop/migration/add_index.rb index d9247a1f7ea..5e6766f6994 100644 --- a/rubocop/cop/migration/add_index.rb +++ b/rubocop/cop/migration/add_index.rb @@ -1,3 +1,5 @@ +require_relative '../../migration_helpers' + module RuboCop module Cop module Migration @@ -5,7 +7,7 @@ module RuboCop class AddIndex < RuboCop::Cop::Cop include MigrationHelpers - MSG = 'add_index requires downtime, use add_concurrent_index instead' + MSG = '`add_index` requires downtime, use `add_concurrent_index` instead' def on_def(node) return unless in_migration?(node) diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 7f20754ee51..3e292a4527c 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -1,4 +1,4 @@ -require_relative 'migration_helpers' require_relative 'cop/migration/add_index' -require_relative 'cop/migration/column_with_default' +require_relative 'cop/migration/add_column' +require_relative 'cop/migration/add_column_with_default' require_relative 'cop/gem_fetcher' diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb index 91d6f39a5bf..275561502cd 100644 --- a/spec/factories/todos.rb +++ b/spec/factories/todos.rb @@ -24,6 +24,10 @@ FactoryGirl.define do target factory: :merge_request end + trait :marked do + action { Todo::MARKED } + end + trait :approval_required do action { Todo::APPROVAL_REQUIRED } end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 34f47daf0e5..7225f38b7e5 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' describe 'Issue Boards', feature: true, js: true do include WaitForAjax include WaitForVueResource + include DragTo let(:project) { create(:empty_project, :public) } let(:board) { create(:board, project: project) } @@ -188,7 +189,7 @@ describe 'Issue Boards', feature: true, js: true do end it 'moves issue to done' do - drag_to(list_from_index: 0, list_to_index: 2) + drag(list_from_index: 0, list_to_index: 2) wait_for_board_cards(1, 7) wait_for_board_cards(2, 2) @@ -201,7 +202,7 @@ describe 'Issue Boards', feature: true, js: true do end it 'removes all of the same issue to done' do - drag_to(list_from_index: 0, list_to_index: 2) + drag(list_from_index: 0, list_to_index: 2) wait_for_board_cards(1, 7) wait_for_board_cards(2, 2) @@ -215,7 +216,7 @@ describe 'Issue Boards', feature: true, js: true do context 'lists' do it 'changes position of list' do - drag_to(list_from_index: 1, list_to_index: 0, selector: '.board-header') + drag(list_from_index: 1, list_to_index: 0, selector: '.board-header') wait_for_board_cards(1, 2) wait_for_board_cards(2, 8) @@ -226,7 +227,7 @@ describe 'Issue Boards', feature: true, js: true do end it 'issue moves between lists' do - drag_to(list_from_index: 0, card_index: 1, list_to_index: 1) + drag(list_from_index: 0, from_index: 1, list_to_index: 1) wait_for_board_cards(1, 7) wait_for_board_cards(2, 2) @@ -237,7 +238,7 @@ describe 'Issue Boards', feature: true, js: true do end it 'issue moves between lists' do - drag_to(list_from_index: 1, list_to_index: 0) + drag(list_from_index: 1, list_to_index: 0) wait_for_board_cards(1, 9) wait_for_board_cards(2, 1) @@ -248,7 +249,7 @@ describe 'Issue Boards', feature: true, js: true do end it 'issue moves from done' do - drag_to(list_from_index: 2, list_to_index: 1) + drag(list_from_index: 2, list_to_index: 1) expect(find('.board:nth-child(2)')).to have_content(issue8.title) @@ -615,14 +616,13 @@ describe 'Issue Boards', feature: true, js: true do end end - def drag_to(list_from_index: 0, card_index: 0, to_index: 0, list_to_index: 0, selector: '.board-list') - evaluate_script("simulateDrag({scrollable: document.getElementById('board-app'), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{card_index}}, to: {el: $('.board-list').eq(#{list_to_index}).get(0), index: #{to_index}}});") - - Timeout.timeout(Capybara.default_max_wait_time) do - loop until page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero? - end - - wait_for_vue_resource + def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0) + drag_to(selector: selector, + scrollable: '#board-app', + list_from_index: list_from_index, + from_index: from_index, + to_index: to_index, + list_to_index: list_to_index) end def wait_for_board_cards(board_number, expected_cards) diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 9cc50167395..bad6b56a18a 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -241,7 +241,7 @@ describe 'Issue Boards', feature: true, js: true do page.within('.due_date') do click_link 'Edit' - click_link Date.today.day + click_button Date.today.day wait_for_vue_resource diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index a515c92db37..37b7c20239f 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -45,6 +45,23 @@ feature 'Group', feature: true do end end + describe 'create a nested group' do + let(:group) { create(:group, path: 'foo') } + + before do + visit subgroups_group_path(group) + click_link 'New Subgroup' + end + + it 'creates a nested group' do + fill_in 'Group path', with: 'bar' + click_button 'Create group' + + expect(current_path).to eq(group_path('foo/bar')) + expect(page).to have_content("Group 'bar' was successfully created.") + end + end + describe 'group edit' do let(:group) { create(:group) } let(:path) { edit_group_path(group) } @@ -117,7 +134,7 @@ feature 'Group', feature: true do visit path click_link 'Subgroups' - expect(page).to have_content(nested_group.full_name) + expect(page).to have_content(nested_group.name) end end end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 3f70a6aa75f..6f7046c8461 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -801,4 +801,26 @@ describe 'Filter issues', js: true, feature: true do expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) end end + + context 'URL has a trailing slash' do + before do + visit "#{namespace_project_issues_path(project.namespace, project)}/" + end + + it 'milestone dropdown loads milestones' do + input_filtered_search("milestone:", submit: false) + + within('#js-dropdown-milestone') do + expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 2) + end + end + + it 'label dropdown load labels' do + input_filtered_search("label:", submit: false) + + within('#js-dropdown-label') do + expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5) + end + end + end end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 394eb31aff8..755162a1eb5 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -78,8 +78,8 @@ describe 'Issues', feature: true do fill_in 'issue_description', with: 'bug description' find('#issuable-due-date').click - page.within '.ui-datepicker' do - click_link date.day + page.within '.pika-single' do + click_button date.day end expect(find('#issuable-due-date').value).to eq date.to_s @@ -110,8 +110,8 @@ describe 'Issues', feature: true do fill_in 'issue_description', with: 'bug description' find('#issuable-due-date').click - page.within '.ui-datepicker' do - click_link date.day + page.within '.pika-single' do + click_button date.day end expect(find('#issuable-due-date').value).to eq date.to_s @@ -624,8 +624,8 @@ describe 'Issues', feature: true do page.within '.due_date' do click_link 'Edit' - page.within '.ui-datepicker-calendar' do - click_link date.day + page.within '.pika-single' do + click_button date.day end wait_for_ajax @@ -635,11 +635,13 @@ describe 'Issues', feature: true do end it 'removes due date from issue' do + date = Date.today.at_beginning_of_month + 2.days + page.within '.due_date' do click_link 'Edit' - page.within '.ui-datepicker-calendar' do - first('.ui-state-default').click + page.within '.pika-single' do + click_button date.day end wait_for_ajax diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index fb3a1ae4bd0..957e913bf95 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -29,8 +29,6 @@ describe 'Merge request', :feature, :js do it 'shows widget status after creating new merge request' do click_button 'Submit merge request' - expect(find('.mr-state-widget')).to have_content('Checking ability to merge automatically') - wait_for_ajax expect(page).to have_selector('.accept_merge_request') diff --git a/spec/features/milestones/milestones_spec.rb b/spec/features/milestones/milestones_spec.rb index aadd72a9f8e..8de9942c54e 100644 --- a/spec/features/milestones/milestones_spec.rb +++ b/spec/features/milestones/milestones_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' describe 'Milestone draggable', feature: true, js: true do include WaitForAjax + include DragTo let(:milestone) { create(:milestone, project: project, title: 8.14) } let(:project) { create(:empty_project, :public) } @@ -75,7 +76,7 @@ describe 'Milestone draggable', feature: true, js: true do create(:issue, params.merge(title: 'Foo', project: project, milestone: milestone)) visit namespace_project_milestone_path(project.namespace, project, milestone) - issue.drag_to(issue_target) + drag_to(selector: '.issues-sortable-list', list_to_index: 1) wait_for_ajax end @@ -85,7 +86,7 @@ describe 'Milestone draggable', feature: true, js: true do visit namespace_project_milestone_path(project.namespace, project, milestone) page.find("a[href='#tab-merge-requests']").click - merge_request.drag_to(merge_request_target) + drag_to(selector: '.merge_requests-sortable-list', list_to_index: 1) wait_for_ajax end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 55a01057c83..eb7b8a24669 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -34,7 +34,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do # Set date to 1st of next month find_field("Expires at").trigger('focus') - find("a[title='Next']").click + find(".pika-next").click click_on "1" # Scopes diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb index 97ce9cdfd87..1e900d7e660 100644 --- a/spec/features/projects/labels/update_prioritization_spec.rb +++ b/spec/features/projects/labels/update_prioritization_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' feature 'Prioritize labels', feature: true do include WaitForAjax + include DragTo let(:user) { create(:user) } let(:group) { create(:group) } @@ -99,7 +100,7 @@ feature 'Prioritize labels', feature: true do expect(page).to have_content 'wontfix' # Sort labels - find("#project_label_#{bug.id}").drag_to find("#group_label_#{feature.id}") + drag_to(selector: '.js-prioritized-labels', from_index: 1, to_index: 2) page.within('.prioritized-labels') do expect(first('li')).to have_content('feature') diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb index f136d9ce0fa..c3f45be6e4b 100644 --- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb +++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb @@ -14,15 +14,16 @@ feature 'Projects > Members > Master adds member with expiration date', feature: login_as(master) end - scenario 'expiration date is displayed in the members list', js: true do + scenario 'expiration date is displayed in the members list' do travel_to Time.zone.parse('2016-08-06 08:00') do - visit namespace_project_settings_members_path(project.namespace, project) + date = 4.days.from_now + visit namespace_project_project_members_path(project.namespace, project) + page.within '.users-project-form' do select2(new_member.id, from: '#user_ids', multiple: true) - fill_in 'expires_at', with: '2016-08-10' + fill_in 'expires_at', with: date.to_s(:medium) + click_on 'Add to project' end - find('.users-project-form').click - click_on 'Add to project' page.within "#project_member_#{new_member.project_members.first.id}" do expect(page).to have_content('Expires in 4 days') @@ -32,11 +33,12 @@ feature 'Projects > Members > Master adds member with expiration date', feature: scenario 'change expiration date' do travel_to Time.zone.parse('2016-08-06 08:00') do - project.team.add_users([new_member.id], :developer, expires_at: '2016-09-06') + date = 3.days.from_now + project.team.add_users([new_member.id], :developer, expires_at: Date.today.to_s(:medium)) visit namespace_project_project_members_path(project.namespace, project) page.within "#project_member_#{new_member.project_members.first.id}" do - find('.js-access-expiration-date').set '2016-08-09' + find('.js-access-expiration-date').set date.to_s(:medium) wait_for_ajax expect(page).to have_content('Expires in 3 days') end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index ca18ac073d8..6555b2fc6c1 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -35,6 +35,10 @@ describe 'Pipelines', :feature, :js do it 'contains pipeline commit short SHA' do expect(page).to have_content(pipeline.short_sha) end + + it 'contains branch name' do + expect(page).to have_content(pipeline.ref) + end end end diff --git a/spec/features/todos/todos_filtering_spec.rb b/spec/features/todos/todos_filtering_spec.rb index d1f2bc78884..e8f06916d53 100644 --- a/spec/features/todos/todos_filtering_spec.rb +++ b/spec/features/todos/todos_filtering_spec.rb @@ -98,15 +98,58 @@ describe 'Dashboard > User filters todos', feature: true, js: true do expect(find('.todos-list')).not_to have_content merge_request.to_reference end - it 'filters by action' do - click_button 'Action' - within '.dropdown-menu-action' do - click_link 'Assigned' + describe 'filter by action' do + before do + create(:todo, :build_failed, user: user_1, author: user_2, project: project_1) + create(:todo, :marked, user: user_1, author: user_2, project: project_1, target: issue) end - wait_for_ajax + it 'filters by Assigned' do + filter_action('Assigned') + + expect_to_see_action(:assigned) + end + + it 'filters by Mentioned' do + filter_action('Mentioned') + + expect_to_see_action(:mentioned) + end + + it 'filters by Added' do + filter_action('Added') + + expect_to_see_action(:marked) + end + + it 'filters by Pipelines' do + filter_action('Pipelines') - expect(find('.todos-list')).to have_content ' assigned you ' - expect(find('.todos-list')).not_to have_content ' mentioned ' + expect_to_see_action(:build_failed) + end + + def filter_action(name) + click_button 'Action' + within '.dropdown-menu-action' do + click_link name + end + + wait_for_ajax + end + + def expect_to_see_action(action_name) + action_names = { + assigned: ' assigned you ', + mentioned: ' mentioned ', + marked: ' added a todo for ', + build_failed: ' build failed for ' + } + + action_name_text = action_names.delete(action_name) + expect(find('.todos-list')).to have_content action_name_text + action_names.each_value do |other_action_text| + expect(find('.todos-list')).not_to have_content other_action_text + end + end end end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index 8d268dbcf36..25f23826648 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -79,4 +79,86 @@ describe MergeRequestsHelper do expect(mr_widget_refresh_url(nil)).to eq('') end end + + describe '#mr_closes_issues' do + let(:user_1) { create(:user) } + let(:user_2) { create(:user) } + + let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) } + let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) } + + let(:issue_1) { create(:issue, project: project_1) } + let(:issue_2) { create(:issue, project: project_2) } + + let(:merge_request) { create(:merge_request, source_project: project_1, target_project: project_1,) } + + let(:merge_request) do + create(:merge_request, + source_project: project_1, target_project: project_1, + description: "Fixes #{issue_1.to_reference} Fixes #{issue_2.to_reference(project_1)}") + end + + before do + project_1.team << [user_2, :developer] + project_2.team << [user_2, :developer] + allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch) + @merge_request = merge_request + end + + context 'user without access to another private project' do + let(:current_user) { user_1 } + + it 'cannot see that project\'s issue that will be closed on acceptance' do + expect(mr_closes_issues).to contain_exactly(issue_1) + end + end + + context 'user with access to another private project' do + let(:current_user) { user_2 } + + it 'can see that project\'s issue that will be closed on acceptance' do + expect(mr_closes_issues).to contain_exactly(issue_1, issue_2) + end + end + end + + describe '#mr_issues_mentioned_but_not_closing' do + let(:user_1) { create(:user) } + let(:user_2) { create(:user) } + + let(:project_1) { create(:project, :private, creator: user_1, namespace: user_1.namespace) } + let(:project_2) { create(:project, :private, creator: user_2, namespace: user_2.namespace) } + + let(:issue_1) { create(:issue, project: project_1) } + let(:issue_2) { create(:issue, project: project_2) } + + let(:merge_request) do + create(:merge_request, + source_project: project_1, target_project: project_1, + description: "#{issue_1.to_reference} #{issue_2.to_reference(project_1)}") + end + + before do + project_1.team << [user_2, :developer] + project_2.team << [user_2, :developer] + allow(merge_request.project).to receive(:default_branch).and_return(merge_request.target_branch) + @merge_request = merge_request + end + + context 'user without access to another private project' do + let(:current_user) { user_1 } + + it 'cannot see that project\'s issue that will be closed on acceptance' do + expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1) + end + end + + context 'user with access to another private project' do + let(:current_user) { user_2 } + + it 'can see that project\'s issue that will be closed on acceptance' do + expect(mr_issues_mentioned_but_not_closing).to contain_exactly(issue_1, issue_2) + end + end + end end diff --git a/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 b/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 index 559723bafcc..789f5dc9f49 100644 --- a/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 +++ b/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 @@ -7,6 +7,9 @@ describe('Store', () => { store = new gl.commits.pipelines.PipelinesStore(); }); + // unregister intervals and event handlers + afterEach(() => gl.VueRealtimeListener.reset()); + it('should start with a blank state', () => { expect(store.state.pipelines.length).toBe(0); }); diff --git a/spec/lib/gitlab/serialize/ci/variables_spec.rb b/spec/lib/gitlab/serializer/ci/variables_spec.rb index 7ea74da5252..b810c68ea03 100644 --- a/spec/lib/gitlab/serialize/ci/variables_spec.rb +++ b/spec/lib/gitlab/serializer/ci/variables_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Serialize::Ci::Variables do +describe Gitlab::Serializer::Ci::Variables do subject do described_class.load(described_class.dump(object)) end diff --git a/spec/lib/gitlab/serializer/pagination_spec.rb b/spec/lib/gitlab/serializer/pagination_spec.rb new file mode 100644 index 00000000000..519eb1b274f --- /dev/null +++ b/spec/lib/gitlab/serializer/pagination_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Gitlab::Serializer::Pagination do + let(:request) { spy('request') } + let(:response) { spy('response') } + let(:headers) { spy('headers') } + + before do + allow(request).to receive(:query_parameters) + .and_return(params) + + allow(response).to receive(:headers) + .and_return(headers) + end + + let(:pagination) { described_class.new(request, response) } + + describe '#paginate' do + subject { pagination.paginate(resource) } + + let(:resource) { User.all } + let(:params) { { page: 1, per_page: 2 } } + + context 'when a multiple resources are present in relation' do + before { create_list(:user, 3) } + + it 'correctly paginates the resource' do + expect(subject.count).to be 2 + end + + it 'appends relevant headers' do + expect(headers).to receive(:[]=).with('X-Total', '3') + expect(headers).to receive(:[]=).with('X-Total-Pages', '2') + expect(headers).to receive(:[]=).with('X-Per-Page', '2') + + subject + end + end + + context 'when an invalid resource is about to be paginated' do + let(:resource) { create(:user) } + + it 'raises error' do + expect { subject }.to raise_error( + described_class::InvalidResourceError) + end + end + end +end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 30443534cca..e008ec28fa4 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -14,12 +14,14 @@ describe Group, 'Routable' do describe 'Callbacks' do it 'creates route record on create' do expect(group.route.path).to eq(group.path) + expect(group.route.name).to eq(group.name) end it 'updates route record on path change' do - group.update_attributes(path: 'wow') + group.update_attributes(path: 'wow', name: 'much') expect(group.route.path).to eq('wow') + expect(group.route.name).to eq('much') end it 'ensure route path uniqueness across different objects' do @@ -78,4 +80,34 @@ describe Group, 'Routable' do it { is_expected.to eq([nested_group]) } end + + describe '#full_path' do + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + + it { expect(group.full_path).to eq(group.path) } + it { expect(nested_group.full_path).to eq("#{group.path}/#{nested_group.path}") } + end + + describe '#full_name' do + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + + it { expect(group.full_name).to eq(group.name) } + it { expect(nested_group.full_name).to eq("#{group.name} / #{nested_group.name}") } + end +end + +describe Project, 'Routable' do + describe '#full_path' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project.full_path).to eq "#{project.namespace.path}/#{project.path}" } + end + + describe '#full_name' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project.full_name).to eq "#{project.namespace.human_name} / #{project.name}" } + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index e1e99300489..a01741a9971 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -97,7 +97,7 @@ describe MergeRequest, models: true do commit = double('commit1', safe_message: "Fixes #{issue.to_reference}") allow(subject).to receive(:commits).and_return([commit]) - expect { subject.cache_merge_request_closes_issues! }.to change(subject.merge_requests_closing_issues, :count).by(1) + expect { subject.cache_merge_request_closes_issues!(subject.author) }.to change(subject.merge_requests_closing_issues, :count).by(1) end it 'does not cache issues from external trackers' do @@ -106,7 +106,7 @@ describe MergeRequest, models: true do commit = double('commit1', safe_message: "Fixes #{issue.to_reference}") allow(subject).to receive(:commits).and_return([commit]) - expect { subject.cache_merge_request_closes_issues! }.not_to change(subject.merge_requests_closing_issues, :count) + expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count) end end @@ -300,7 +300,7 @@ describe MergeRequest, models: true do allow(subject.project).to receive(:default_branch). and_return(subject.target_branch) - expect(subject.issues_mentioned_but_not_closing).to match_array([mentioned_issue]) + expect(subject.issues_mentioned_but_not_closing(subject.author)).to match_array([mentioned_issue]) end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 7bb1657bc3a..35d932f1c64 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -3,21 +3,32 @@ require 'spec_helper' describe Namespace, models: true do let!(:namespace) { create(:namespace) } - it { is_expected.to have_many :projects } - it { is_expected.to have_many :project_statistics } - it { is_expected.to belong_to :parent } - it { is_expected.to have_many :children } + describe 'associations' do + it { is_expected.to have_many :projects } + it { is_expected.to have_many :project_statistics } + it { is_expected.to belong_to :parent } + it { is_expected.to have_many :children } + end - it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) } - it { is_expected.to validate_length_of(:name).is_at_most(255) } + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) } + it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.to validate_length_of(:description).is_at_most(255) } + it { is_expected.to validate_presence_of(:path) } + it { is_expected.to validate_length_of(:path).is_at_most(255) } + it { is_expected.to validate_presence_of(:owner) } - it { is_expected.to validate_length_of(:description).is_at_most(255) } + it 'does not allow too deep nesting' do + ancestors = (1..21).to_a + nested = build(:namespace, parent: namespace) - it { is_expected.to validate_presence_of(:path) } - it { is_expected.to validate_length_of(:path).is_at_most(255) } + allow(nested).to receive(:ancestors).and_return(ancestors) - it { is_expected.to validate_presence_of(:owner) } + expect(nested).not_to be_valid + expect(nested.errors[:parent_id].first).to eq('has too deep level of nesting') + end + end describe "Respond to" do it { is_expected.to respond_to(:human_name) } @@ -175,22 +186,6 @@ describe Namespace, models: true do end end - describe '#full_path' do - let(:group) { create(:group) } - let(:nested_group) { create(:group, parent: group) } - - it { expect(group.full_path).to eq(group.path) } - it { expect(nested_group.full_path).to eq("#{group.path}/#{nested_group.path}") } - end - - describe '#full_name' do - let(:group) { create(:group) } - let(:nested_group) { create(:group, parent: group) } - - it { expect(group.full_name).to eq(group.name) } - it { expect(nested_group.full_name).to eq("#{group.name} / #{nested_group.name}") } - end - describe '#ancestors' do let(:group) { create(:group) } let(:nested_group) { create(:group, parent: group) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 2129bcbd74d..35f3dd00870 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -275,13 +275,6 @@ describe Project, models: true do it { is_expected.to delegate_method(:add_master).to(:team) } end - describe '#name_with_namespace' do - let(:project) { build_stubbed(:empty_project) } - - it { expect(project.name_with_namespace).to eq "#{project.namespace.human_name} / #{project.name}" } - it { expect(project.human_name).to eq project.name_with_namespace } - end - describe '#to_reference' do let(:owner) { create(:user, name: 'Gitlab') } let(:namespace) { create(:namespace, path: 'sample-namespace', owner: owner) } @@ -1840,6 +1833,20 @@ describe Project, models: true do end end + describe '#parent' do + let(:project) { create(:empty_project) } + + it { expect(project.parent).to eq(project.namespace) } + end + + describe '#parent_changed?' do + let(:project) { create(:empty_project) } + + before { project.namespace_id = 7 } + + it { expect(project.parent_changed?).to be_truthy } + end + def enable_lfs allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) end diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index dd2a5109abc..0b222022e62 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Route, models: true do - let!(:group) { create(:group, path: 'gitlab') } + let!(:group) { create(:group, path: 'gitlab', name: 'gitlab') } let!(:route) { group.route } describe 'relationships' do @@ -15,17 +15,42 @@ describe Route, models: true do end describe '#rename_descendants' do - let!(:nested_group) { create(:group, path: "test", parent: group) } - let!(:deep_nested_group) { create(:group, path: "foo", parent: nested_group) } - let!(:similar_group) { create(:group, path: 'gitlab-org') } + let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) } + let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) } + let!(:similar_group) { create(:group, path: 'gitlab-org', name: 'gitlab-org') } - before { route.update_attributes(path: 'bar') } + context 'path update' do + context 'when route name is set' do + before { route.update_attributes(path: 'bar') } - it "updates children routes with new path" do - expect(described_class.exists?(path: 'bar')).to be_truthy - expect(described_class.exists?(path: 'bar/test')).to be_truthy - expect(described_class.exists?(path: 'bar/test/foo')).to be_truthy - expect(described_class.exists?(path: 'gitlab-org')).to be_truthy + it "updates children routes with new path" do + expect(described_class.exists?(path: 'bar')).to be_truthy + expect(described_class.exists?(path: 'bar/test')).to be_truthy + expect(described_class.exists?(path: 'bar/test/foo')).to be_truthy + expect(described_class.exists?(path: 'gitlab-org')).to be_truthy + end + end + + context 'when route name is nil' do + before do + route.update_column(:name, nil) + end + + it "does not fail" do + expect(route.update_attributes(path: 'bar')).to be_truthy + end + end + end + + context 'name update' do + before { route.update_attributes(name: 'bar') } + + it "updates children routes with new path" do + expect(described_class.exists?(name: 'bar')).to be_truthy + expect(described_class.exists?(name: 'bar / test')).to be_truthy + expect(described_class.exists?(name: 'bar / test / foo')).to be_truthy + expect(described_class.exists?(name: 'gitlab-org')).to be_truthy + end end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index fe88ec63af6..7fd49c73b37 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -141,6 +141,11 @@ describe User, models: true do user = build(:user, email: "example@test.com") expect(user).to be_invalid end + + it 'accepts example@test.com when added by another user' do + user = build(:user, email: "example@test.com", created_by_id: 1) + expect(user).to be_valid + end end context 'domain blacklist' do @@ -159,6 +164,11 @@ describe User, models: true do user = build(:user, email: 'info@example.com') expect(user).not_to be_valid end + + it 'accepts info@example.com when added by another user' do + user = build(:user, email: 'info@example.com', created_by_id: 1) + expect(user).to be_valid + end end context 'when a signup domain is blacklisted but a wildcard subdomain is allowed' do diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index a027c23bb88..15592f1f702 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -179,6 +179,7 @@ describe API::Groups, api: true do expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) expect(json_response['full_name']).to eq(group1.full_name) expect(json_response['full_path']).to eq(group1.full_path) + expect(json_response['parent_id']).to eq(group1.parent_id) expect(json_response['projects']).to be_an Array expect(json_response['projects'].length).to eq(2) expect(json_response['shared_projects']).to be_an Array @@ -398,6 +399,19 @@ describe API::Groups, api: true do expect(json_response["request_access_enabled"]).to eq(group[:request_access_enabled]) end + it "creates a nested group" do + parent = create(:group) + parent.add_owner(user3) + group = attributes_for(:group, { parent_id: parent.id }) + + post api("/groups", user3), group + + expect(response).to have_http_status(201) + + expect(json_response["full_path"]).to eq("#{parent.path}/#{group[:path]}") + expect(json_response["parent_id"]).to eq(parent.id) + end + it "does not create group, duplicate" do post api("/groups", user3), { name: 'Duplicate Test', path: group2.path } diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 225e2e005df..ac0bbec44e0 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -359,13 +359,6 @@ describe API::Projects, api: true do expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) end - it 'sets a project as public using :public' do - project = attributes_for(:project, { public: true }) - post api('/projects', user), project - expect(json_response['public']).to be_truthy - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) - end - it 'sets a project as internal' do project = attributes_for(:project, :internal) post api('/projects', user), project @@ -373,13 +366,6 @@ describe API::Projects, api: true do expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) end - it 'sets a project as internal overriding :public' do - project = attributes_for(:project, :internal, { public: true }) - post api('/projects', user), project - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) - end - it 'sets a project as private' do project = attributes_for(:project, :private) post api('/projects', user), project @@ -387,13 +373,6 @@ describe API::Projects, api: true do expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) end - it 'sets a project as private using :public' do - project = attributes_for(:project, { public: false }) - post api('/projects', user), project - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) - end - it 'sets a project as allowing merge even if build fails' do project = attributes_for(:project, { only_allow_merge_if_build_succeeds: false }) post api('/projects', user), project @@ -431,13 +410,14 @@ describe API::Projects, api: true do end context 'when a visibility level is restricted' do + let(:project_param) { attributes_for(:project, :public) } + before do - @project = attributes_for(:project, { public: true }) stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) end it 'does not allow a non-admin to use a restricted visibility level' do - post api('/projects', user), @project + post api('/projects', user), project_param expect(response).to have_http_status(400) expect(json_response['message']['visibility_level'].first).to( @@ -446,7 +426,8 @@ describe API::Projects, api: true do end it 'allows an admin to override restricted visibility settings' do - post api('/projects', admin), @project + post api('/projects', admin), project_param + expect(json_response['public']).to be_truthy expect(json_response['visibility_level']).to( eq(Gitlab::VisibilityLevel::PUBLIC) @@ -499,15 +480,6 @@ describe API::Projects, api: true do expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) end - it 'sets a project as public using :public' do - project = attributes_for(:project, { public: true }) - post api("/projects/user/#{user.id}", admin), project - - expect(response).to have_http_status(201) - expect(json_response['public']).to be_truthy - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) - end - it 'sets a project as internal' do project = attributes_for(:project, :internal) post api("/projects/user/#{user.id}", admin), project @@ -517,14 +489,6 @@ describe API::Projects, api: true do expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) end - it 'sets a project as internal overriding :public' do - project = attributes_for(:project, :internal, { public: true }) - post api("/projects/user/#{user.id}", admin), project - expect(response).to have_http_status(201) - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) - end - it 'sets a project as private' do project = attributes_for(:project, :private) post api("/projects/user/#{user.id}", admin), project @@ -532,13 +496,6 @@ describe API::Projects, api: true do expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) end - it 'sets a project as private using :public' do - project = attributes_for(:project, { public: false }) - post api("/projects/user/#{user.id}", admin), project - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) - end - it 'sets a project as allowing merge even if build fails' do project = attributes_for(:project, { only_allow_merge_if_build_succeeds: false }) post api("/projects/user/#{user.id}", admin), project @@ -865,7 +822,7 @@ describe API::Projects, api: true do it 'creates a new project snippet' do post api("/projects/#{project.id}/snippets", user), title: 'api test', file_name: 'sample.rb', code: 'test', - visibility_level: '0' + visibility_level: Gitlab::VisibilityLevel::PRIVATE expect(response).to have_http_status(201) expect(json_response['title']).to eq('api test') end @@ -1114,7 +1071,7 @@ describe API::Projects, api: true do end it 'updates visibility_level' do - project_param = { visibility_level: 20 } + project_param = { visibility_level: Gitlab::VisibilityLevel::PUBLIC } put api("/projects/#{project3.id}", user), project_param expect(response).to have_http_status(200) project_param.each_pair do |k, v| @@ -1124,7 +1081,7 @@ describe API::Projects, api: true do it 'updates visibility_level from public to private' do project3.update_attributes({ visibility_level: Gitlab::VisibilityLevel::PUBLIC }) - project_param = { public: false } + project_param = { visibility_level: Gitlab::VisibilityLevel::PRIVATE } put api("/projects/#{project3.id}", user), project_param expect(response).to have_http_status(200) project_param.each_pair do |k, v| @@ -1197,7 +1154,7 @@ describe API::Projects, api: true do end it 'does not update visibility_level' do - project_param = { visibility_level: 20 } + project_param = { visibility_level: Gitlab::VisibilityLevel::PUBLIC } put api("/projects/#{project3.id}", user4), project_param expect(response).to have_http_status(403) end diff --git a/spec/rubocop/cop/migration/add_column_with_default_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_spec.rb new file mode 100644 index 00000000000..6b9b6b19650 --- /dev/null +++ b/spec/rubocop/cop/migration/add_column_with_default_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/migration/add_column_with_default' + +describe RuboCop::Cop::Migration::AddColumnWithDefault do + include CopHelper + + subject(:cop) { described_class.new } + + context 'in migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + end + + it 'registers an offense when add_column_with_default is used inside a change method' do + inspect_source(cop, 'def change; add_column_with_default :table, :column, default: false; end') + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + end + end + + it 'registers no offense when add_column_with_default is used inside an up method' do + inspect_source(cop, 'def up; add_column_with_default :table, :column, default: false; end') + + expect(cop.offenses.size).to eq(0) + end + end + + context 'outside of migration' do + it 'registers no offense' do + inspect_source(cop, 'def change; add_column_with_default :table, :column, default: false; end') + + expect(cop.offenses.size).to eq(0) + end + end +end diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb index 3c37660885d..1b95f1ff198 100644 --- a/spec/serializers/environment_serializer_spec.rb +++ b/spec/serializers/environment_serializer_spec.rb @@ -52,4 +52,136 @@ describe EnvironmentSerializer do expect(json).to be_an_instance_of Array end end + + context 'when representing environments within folders' do + let(:serializer) do + described_class.new(project: project).within_folders + end + + let(:resource) { Environment.all } + + subject { serializer.represent(resource) } + + context 'when there is a single environment' do + before { create(:environment, name: 'staging') } + + it 'represents one standalone environment' do + expect(subject.count).to eq 1 + expect(subject.first[:name]).to eq 'staging' + expect(subject.first[:size]).to eq 1 + expect(subject.first[:latest][:name]).to eq 'staging' + end + end + + context 'when there are multiple environments in folder' do + before do + create(:environment, name: 'staging/my-review-1') + create(:environment, name: 'staging/my-review-2') + end + + it 'represents one item that is a folder' do + expect(subject.count).to eq 1 + expect(subject.first[:name]).to eq 'staging' + expect(subject.first[:size]).to eq 2 + expect(subject.first[:latest][:name]).to eq 'staging/my-review-2' + expect(subject.first[:latest][:environment_type]).to eq 'staging' + end + end + + context 'when there are multiple folders and standalone environments' do + before do + create(:environment, name: 'staging/my-review-1') + create(:environment, name: 'staging/my-review-2') + create(:environment, name: 'production/my-review-3') + create(:environment, name: 'testing') + end + + it 'represents multiple items grouped within folders' do + expect(subject.count).to eq 3 + + expect(subject.first[:name]).to eq 'production' + expect(subject.first[:size]).to eq 1 + expect(subject.first[:latest][:name]).to eq 'production/my-review-3' + expect(subject.first[:latest][:environment_type]).to eq 'production' + expect(subject.second[:name]).to eq 'staging' + expect(subject.second[:size]).to eq 2 + expect(subject.second[:latest][:name]).to eq 'staging/my-review-2' + expect(subject.second[:latest][:environment_type]).to eq 'staging' + expect(subject.third[:name]).to eq 'testing' + expect(subject.third[:size]).to eq 1 + expect(subject.third[:latest][:name]).to eq 'testing' + expect(subject.third[:latest][:environment_type]).to be_nil + end + end + end + + context 'when used with pagination' do + let(:request) { spy('request') } + let(:response) { spy('response') } + let(:resource) { Environment.all } + let(:pagination) { { page: 1, per_page: 2 } } + + let(:serializer) do + described_class.new(project: project) + .with_pagination(request, response) + end + + before do + allow(request).to receive(:query_parameters) + .and_return(pagination) + end + + subject { serializer.represent(resource) } + + it 'creates a paginated serializer' do + expect(serializer).to be_paginated + end + + context 'when resource is paginatable relation' do + context 'when there is a single environment object in relation' do + before { create(:environment) } + + it 'serializes environments' do + expect(subject.first).to have_key :id + end + end + + context 'when multiple environment objects are serialized' do + before { create_list(:environment, 3) } + + it 'serializes appropriate number of objects' do + expect(subject.count).to be 2 + end + + it 'appends relevant headers' do + expect(response).to receive(:[]=).with('X-Total', '3') + expect(response).to receive(:[]=).with('X-Total-Pages', '2') + expect(response).to receive(:[]=).with('X-Per-Page', '2') + + subject + end + end + + context 'when grouping environments within folders' do + let(:serializer) do + described_class.new(project: project) + .with_pagination(request, response) + .within_folders + end + + before do + create(:environment, name: 'staging/review-1') + create(:environment, name: 'staging/review-2') + create(:environment, name: 'production/deploy-3') + create(:environment, name: 'testing') + end + + it 'paginates grouped items including ordering' do + expect(subject.count).to eq 2 + expect(subject.first[:name]).to eq 'production' + expect(subject.second[:name]).to eq 'staging' + end + end + end + end end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index 7cbf131e41e..2aaef03cb93 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -52,14 +52,14 @@ describe PipelineSerializer do expect(serializer).to be_paginated end - context 'when resource does is not paginatable' do + context 'when resource is not paginatable' do context 'when a single pipeline object is being serialized' do let(:resource) { create(:ci_empty_pipeline) } let(:pagination) { { page: 1, per_page: 1 } } it 'raises error' do - expect { subject } - .to raise_error(PipelineSerializer::InvalidResourceError) + expect { subject }.to raise_error( + Gitlab::Serializer::Pagination::InvalidResourceError) end end end diff --git a/spec/services/destroy_group_service_spec.rb b/spec/services/groups/destroy_service_spec.rb index 538e85cdc89..f86189b68e9 100644 --- a/spec/services/destroy_group_service_spec.rb +++ b/spec/services/groups/destroy_service_spec.rb @@ -1,13 +1,13 @@ require 'spec_helper' -describe DestroyGroupService, services: true do +describe Groups::DestroyService, services: true do include DatabaseConnectionHelpers - let!(:user) { create(:user) } - let!(:group) { create(:group) } - let!(:project) { create(:project, namespace: group) } + let!(:user) { create(:user) } + let!(:group) { create(:group) } + let!(:project) { create(:project, namespace: group) } let!(:gitlab_shell) { Gitlab::Shell.new } - let!(:remove_path) { group.path + "+#{group.id}+deleted" } + let!(:remove_path) { group.path + "+#{group.id}+deleted" } shared_examples 'group destruction' do |async| context 'database records' do @@ -43,9 +43,9 @@ describe DestroyGroupService, services: true do def destroy_group(group, user, async) if async - DestroyGroupService.new(group, user).async_execute + Groups::DestroyService.new(group, user).async_execute else - DestroyGroupService.new(group, user).execute + Groups::DestroyService.new(group, user).execute end end end @@ -80,7 +80,7 @@ describe DestroyGroupService, services: true do # Kick off the initial group destroy in a new thread, so that # it doesn't share this spec's database transaction. - Thread.new { DestroyGroupService.new(group, user).async_execute }.join(5) + Thread.new { Groups::DestroyService.new(group, user).async_execute }.join(5) group_record = run_with_new_database_connection do |conn| conn.execute("SELECT * FROM namespaces WHERE id = #{group.id}").first diff --git a/spec/services/notes/delete_service_spec.rb b/spec/services/notes/destroy_service_spec.rb index 1d0a747a480..f53f96e0c2b 100644 --- a/spec/services/notes/delete_service_spec.rb +++ b/spec/services/notes/destroy_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Notes::DeleteService, services: true do +describe Notes::DestroyService, services: true do describe '#execute' do it 'deletes a note' do project = create(:empty_project) diff --git a/spec/services/delete_user_service_spec.rb b/spec/services/users/destroy_spec.rb index 418a12a83a9..46e58393218 100644 --- a/spec/services/delete_user_service_spec.rb +++ b/spec/services/users/destroy_spec.rb @@ -1,15 +1,16 @@ require 'spec_helper' -describe DeleteUserService, services: true do +describe Users::DestroyService, services: true do describe "Deletes a user and all their personal projects" do let!(:user) { create(:user) } let!(:current_user) { create(:user) } let!(:namespace) { create(:namespace, owner: user) } let!(:project) { create(:project, namespace: namespace) } + let(:service) { described_class.new(current_user) } context 'no options are given' do it 'deletes the user' do - user_data = DeleteUserService.new(current_user).execute(user) + user_data = service.execute(user) expect { user_data['email'].to eq(user.email) } expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) @@ -19,7 +20,7 @@ describe DeleteUserService, services: true do it 'will delete the project in the near future' do expect_any_instance_of(Projects::DestroyService).to receive(:async_execute).once - DeleteUserService.new(current_user).execute(user) + service.execute(user) end end @@ -30,7 +31,7 @@ describe DeleteUserService, services: true do before do solo_owned.group_members = [member] - DeleteUserService.new(current_user).execute(user) + service.execute(user) end it 'does not delete the user' do @@ -45,7 +46,7 @@ describe DeleteUserService, services: true do before do solo_owned.group_members = [member] - DeleteUserService.new(current_user).execute(user, delete_solo_owned_groups: true) + service.execute(user, delete_solo_owned_groups: true) end it 'deletes solo owned groups' do diff --git a/spec/support/drag_to_helper.rb b/spec/support/drag_to_helper.rb new file mode 100644 index 00000000000..0c0659d3ecd --- /dev/null +++ b/spec/support/drag_to_helper.rb @@ -0,0 +1,13 @@ +module DragTo + def drag_to(list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0, selector: '', scrollable: 'body') + evaluate_script("simulateDrag({scrollable: $('#{scrollable}').get(0), from: {el: $('#{selector}').eq(#{list_from_index}).get(0), index: #{from_index}}, to: {el: $('#{selector}').eq(#{list_to_index}).get(0), index: #{to_index}}});") + + Timeout.timeout(Capybara.default_max_wait_time) do + loop until drag_active? + end + end + + def drag_active? + page.evaluate_script('window.SIMULATE_DRAG_ACTIVE').zero? + end +end diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb index 14c56521280..0765573408c 100644 --- a/spec/workers/delete_user_worker_spec.rb +++ b/spec/workers/delete_user_worker_spec.rb @@ -5,14 +5,14 @@ describe DeleteUserWorker do let!(:current_user) { create(:user) } it "calls the DeleteUserWorker with the params it was given" do - expect_any_instance_of(DeleteUserService).to receive(:execute). + expect_any_instance_of(Users::DestroyService).to receive(:execute). with(user, {}) DeleteUserWorker.new.perform(current_user.id, user.id) end it "uses symbolized keys" do - expect_any_instance_of(DeleteUserService).to receive(:execute). + expect_any_instance_of(Users::DestroyService).to receive(:execute). with(user, test: "test") DeleteUserWorker.new.perform(current_user.id, user.id, "test" => "test") |