Commit cbaa5ced authored by Marin Jankovski's avatar Marin Jankovski

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ee

parents 58278d0c 03a06a86
{"iconCount":189,"spriteSize":85900,"icons":["abuse","account","admin","angle-double-left","angle-double-right","angle-down","angle-left","angle-right","angle-up","appearance","applications","approval","arrow-down","arrow-right","assignee","bold","book","bookmark","branch","bullhorn","calendar","cancel","chart","chevron-down","chevron-left","chevron-right","chevron-up","clock","close","code","collapse","comment-dots","comment-next","comment","comments","commit","credit-card","cut","dashboard","disk","doc_code","doc_image","doc_text","double-headed-arrow","download","duplicate","earth","ellipsis_v","emoji_slightly_smiling_face","emoji_smile","emoji_smiley","epic","external-link","eye-slash","eye","file-addition","file-deletion","file-modified","filter","folder-o","folder-open","folder","fork","geo-nodes","git-merge","group","history","home","hook","hourglass","image-comment-dark","image-comment-light","import","issue-block","issue-child","issue-close","issue-duplicate","issue-external","issue-new","issue-open-m","issue-open","issue-parent","issues","italic","key-2","key","label","labels","leave","level-up","license","link","list-bulleted","list-numbered","location-dot","location","lock-open","lock","log","mail","menu","merge-request-close","messages","mobile-issue-close","monitor","more","notifications-off","notifications","overview","pencil-square","pencil","pipeline","play","plus-square-o","plus-square","plus","podcast","preferences","profile","project","push-rules","question-o","question","quote","redo","remove","repeat","retry","scale","screen-full","screen-normal","scroll_down","scroll_up","search","settings","shield","slight-frown","slight-smile","smile","smiley","snippet","spam","spinner","staged","star-o","star","status_canceled_borderless","status_canceled","status_closed","status_created_borderless","status_created","status_failed_borderless","status_failed","status_manual_borderless","status_manual","status_notfound_borderless","status_notfound","status_open","status_pending_borderless","status_pending","status_running_borderless","status_running","status_skipped_borderless","status_skipped","status_success_borderless","status_success_solid","status_success","status_warning_borderless","status_warning","stop","task-done","template","terminal","thumb-down","thumb-up","thumbtack","timer","todo-add","todo-done","token","unapproval","unassignee","unlink","unstaged","user","users","volume-up","warning","work"]} {"iconCount":191,"spriteSize":86607,"icons":["abuse","account","admin","angle-double-left","angle-double-right","angle-down","angle-left","angle-right","angle-up","appearance","applications","approval","arrow-down","arrow-right","assignee","bold","book","bookmark","branch","bullhorn","calendar","cancel","chart","chevron-down","chevron-left","chevron-right","chevron-up","clock","close","code","collapse","comment-dots","comment-next","comment","comments","commit","credit-card","cut","dashboard","disk","doc_code","doc_image","doc_text","double-headed-arrow","download","duplicate","earth","ellipsis_v","emoji_slightly_smiling_face","emoji_smile","emoji_smiley","epic","external-link","eye-slash","eye","file-addition","file-deletion","file-modified","filter","folder-o","folder-open","folder","fork","geo-nodes","git-merge","group","history","home","hook","hourglass","image-comment-dark","image-comment-light","import","issue-block","issue-child","issue-close","issue-duplicate","issue-external","issue-new","issue-open-m","issue-open","issue-parent","issues","italic","key-2","key","label","labels","leave","level-up","license","link","list-bulleted","list-numbered","location-dot","location","lock-open","lock","log","mail","menu","merge-request-close","messages","mobile-issue-close","monitor","more","notifications-off","notifications","overview","pencil-square","pencil","pipeline","play","plus-square-o","plus-square","plus","podcast","preferences","profile","project","push-rules","question-o","question","quote","redo","remove","repeat","retry","scale","screen-full","screen-normal","scroll_down","scroll_up","search","settings","shield","slight-frown","slight-smile","smile","smiley","snippet","soft-unwrap","soft-wrap","spam","spinner","staged","star-o","star","status_canceled_borderless","status_canceled","status_closed","status_created_borderless","status_created","status_failed_borderless","status_failed","status_manual_borderless","status_manual","status_notfound_borderless","status_notfound","status_open","status_pending_borderless","status_pending","status_running_borderless","status_running","status_skipped_borderless","status_skipped","status_success_borderless","status_success_solid","status_success","status_warning_borderless","status_warning","stop","task-done","template","terminal","thumb-down","thumb-up","thumbtack","timer","todo-add","todo-done","token","unapproval","unassignee","unlink","unstaged","user","users","volume-up","warning","work"]}
\ No newline at end of file \ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
<svg xmlns="http://www.w3.org/2000/svg" width="142" height="104" viewBox="0 0 142 104"><g fill="none" fill-rule="evenodd"><g transform="translate(112 4)"><path fill="#FFF" d="M8 4a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4H8z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M8 4a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4H8zm0-4h14a8 8 0 0 1 8 8v14a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V8a8 8 0 0 1 8-8z"/><rect width="10" height="10" x="10" y="10" fill="#FC6D26" rx="5"/></g><g transform="translate(5 74)"><rect width="30" height="30" fill="#FFF" rx="8"/><path fill="#E1DBF1" fill-rule="nonzero" d="M8 4a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4H8zm0-4h14a8 8 0 0 1 8 8v14a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V8a8 8 0 0 1 8-8z"/><rect width="10" height="10" x="10" y="10" fill="#6B4FBB" rx="5"/></g><path fill="#FFF" d="M6 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6z"/><path fill="#FDC4A8" fill-rule="nonzero" d="M6 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6zm0-4h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6a6 6 0 0 1 6-6z"/><rect width="8" height="8" x="8" y="8" fill="#FC6D26" rx="4"/><g transform="translate(112 77)"><rect width="24" height="24" fill="#FFF" rx="6"/><path fill="#E1DBF1" fill-rule="nonzero" d="M6 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6zm0-4h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6a6 6 0 0 1 6-6z"/><rect width="8" height="8" x="8" y="8" fill="#6B4FBB" rx="4"/></g><g transform="translate(46 29)"><rect width="46" height="46" y="2" fill="#E1DBF1" rx="10"/><rect width="46" height="46" fill="#E1DBF1" rx="10"/><path fill="#C3B8E3" fill-rule="nonzero" d="M10 4a6 6 0 0 0-6 6v26a6 6 0 0 0 6 6h26a6 6 0 0 0 6-6V10a6 6 0 0 0-6-6H10zm0-4h26c5.523 0 10 4.477 10 10v26c0 5.523-4.477 10-10 10H10C4.477 46 0 41.523 0 36V10C0 4.477 4.477 0 10 0z"/><rect width="14" height="14" x="16" y="16" fill="#6B4FBB" rx="2"/></g><path fill="#E1DBF1" fill-rule="nonzero" d="M98.413 35.682a2 2 0 1 1-2.826-2.83l2.122-2.12a2 2 0 1 1 2.827 2.83l-2.123 2.12z"/><path fill="#C3B8E3" d="M104.78 29.32a2 2 0 0 1-2.826-2.829l2.122-2.12a2 2 0 0 1 2.827 2.83l-2.122 2.12z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M42.413 89.682a2 2 0 1 1-2.826-2.83l2.122-2.12a2 2 0 1 1 2.827 2.83l-2.123 2.12z"/><path fill="#E1DBF1" d="M48.78 83.32a2 2 0 1 1-2.826-2.829l2.122-2.12a2 2 0 1 1 2.827 2.83l-2.122 2.12z"/><path fill="#E1DBF1" fill-rule="nonzero" d="M27.713 26.531a2 2 0 1 1 2.574-3.062l2.296 1.93a2 2 0 1 1-2.573 3.062l-2.297-1.93z"/><path fill="#C3B8E3" d="M34.604 32.321a2 2 0 1 1 2.573-3.062l2.297 1.93A2 2 0 0 1 36.9 34.25l-2.297-1.93z"/><path fill="#C3B8E3" fill-rule="nonzero" d="M93.74 74.553a2 2 0 0 1 2.52-3.106l2.33 1.891a2 2 0 1 1-2.521 3.106l-2.33-1.891z"/><path fill="#E1DBF1" d="M100.727 80.225a2 2 0 1 1 2.521-3.105l2.33 1.89a2 2 0 1 1-2.522 3.106l-2.33-1.89z"/></g></svg>
\ No newline at end of file
import _ from 'underscore';
import {
getSelector,
togglePopover,
inserted,
mouseenter,
mouseleave,
} from './feature_highlight_helper';
export function setupFeatureHighlightPopover(id, debounceTimeout = 300) {
const $selector = $(getSelector(id));
const $parent = $selector.parent();
const $popoverContent = $parent.siblings('.feature-highlight-popover-content');
const hideOnScroll = togglePopover.bind($selector, false);
const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout);
$selector
// Setup popover
.data('content', $popoverContent.prop('outerHTML'))
.popover({
html: true,
// Override the existing template to add custom CSS classes
template: `
<div class="popover feature-highlight-popover" role="tooltip">
<div class="arrow"></div>
<div class="popover-content"></div>
</div>
`,
})
.on('mouseenter', mouseenter)
.on('mouseleave', debouncedMouseleave)
.on('inserted.bs.popover', inserted)
.on('show.bs.popover', () => {
window.addEventListener('scroll', hideOnScroll);
})
.on('hide.bs.popover', () => {
window.removeEventListener('scroll', hideOnScroll);
})
// Display feature highlight
.removeAttr('disabled');
}
export function findHighestPriorityFeature() {
let priorityFeature;
const sortedFeatureEls = [].slice.call(document.querySelectorAll('.js-feature-highlight')).sort((a, b) =>
(a.dataset.highlightPriority || 0) < (b.dataset.highlightPriority || 0));
const [priorityFeatureEl] = sortedFeatureEls;
if (priorityFeatureEl) {
priorityFeature = priorityFeatureEl.dataset.highlight;
}
return priorityFeature;
}
export function highlightFeatures() {
const priorityFeature = findHighestPriorityFeature();
if (priorityFeature) {
setupFeatureHighlightPopover(priorityFeature);
}
return priorityFeature;
}
import axios from '../lib/utils/axios_utils';
import { __ } from '../locale';
import Flash from '../flash';
import LazyLoader from '../lazy_loader';
export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`;
export function togglePopover(show) {
const isAlreadyShown = this.hasClass('js-popover-show');
if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) {
return false;
}
this.popover(show ? 'show' : 'hide');
this.toggleClass('disable-animation js-popover-show', show);
return true;
}
export function dismiss(highlightId) {
axios.post(this.attr('data-dismiss-endpoint'), {
feature_name: highlightId,
})
.catch(() => Flash(__('An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.')));
togglePopover.call(this, false);
this.hide();
}
export function mouseleave() {
if (!$('.popover:hover').length > 0) {
const $featureHighlight = $(this);
togglePopover.call($featureHighlight, false);
}
}
export function mouseenter() {
const $featureHighlight = $(this);
const showedPopover = togglePopover.call($featureHighlight, true);
if (showedPopover) {
$('.popover')
.on('mouseleave', mouseleave.bind($featureHighlight));
}
}
export function inserted() {
const popoverId = this.getAttribute('aria-describedby');
const highlightId = this.dataset.highlight;
const $popover = $(this);
const dismissWrapper = dismiss.bind($popover, highlightId);
$(`#${popoverId} .dismiss-feature-highlight`)
.on('click', dismissWrapper);
const lazyImg = $(`#${popoverId} .feature-highlight-illustration`)[0];
if (lazyImg) {
LazyLoader.loadImage(lazyImg);
}
}
import { highlightFeatures } from './feature_highlight';
import bp from '../breakpoints';
export default function domContentLoaded() {
if (bp.getBreakpointSize() === 'lg') {
highlightFeatures();
return true;
}
return false;
}
document.addEventListener('DOMContentLoaded', domContentLoaded);
export default class TransferDropdown {
constructor() {
this.groupDropdown = $('.js-groups-dropdown');
this.parentInput = $('#new_parent_group_id');
this.data = this.groupDropdown.data('data');
this.init();
}
init() {
this.buildDropdown();
}
buildDropdown() {
const extraOptions = [{ id: '', text: 'No parent group' }, 'divider'];
this.groupDropdown.glDropdown({
selectable: true,
filterable: true,
toggleLabel: item => item.text,
search: { fields: ['text'] },
data: extraOptions.concat(this.data),
text: item => item.text,
clicked: (options) => {
const { e } = options;
e.preventDefault();
this.assignSelected(options.selectedObj);
},
});
}
assignSelected(selected) {
this.parentInput.val(selected.id);
}
}
...@@ -26,6 +26,7 @@ import './gl_dropdown'; ...@@ -26,6 +26,7 @@ import './gl_dropdown';
import initTodoToggle from './header'; import initTodoToggle from './header';
import initImporterStatus from './importer_status'; import initImporterStatus from './importer_status';
import initLayoutNav from './layout_nav'; import initLayoutNav from './layout_nav';
import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader'; import LazyLoader from './lazy_loader';
import initLogoAnimation from './logo'; import initLogoAnimation from './logo';
import './milestone_select'; import './milestone_select';
......
import groupAvatar from '~/group_avatar'; import groupAvatar from '~/group_avatar';
import TransferDropdown from '~/groups/transfer_dropdown';
export default groupAvatar; export default () => {
groupAvatar();
new TransferDropdown(); // eslint-disable-line no-new
};
...@@ -61,3 +61,4 @@ ...@@ -61,3 +61,4 @@
@import "framework/stacked-progress-bar"; @import "framework/stacked-progress-bar";
@import "framework/sortable"; @import "framework/sortable";
@import "framework/ci_variable_list"; @import "framework/ci_variable_list";
@import "framework/feature_highlight";
.feature-highlight {
position: relative;
margin-left: $gl-padding;
width: 20px;
height: 20px;
cursor: pointer;
&::before {
content: '';
display: block;
position: absolute;
top: 6px;
left: 6px;
width: 8px;
height: 8px;
background-color: $blue-500;
border-radius: 50%;
box-shadow: 0 0 0 rgba($blue-500, 0.4);
animation: pulse-highlight 2s infinite;
}
&:hover::before,
&.disable-animation::before {
animation: none;
}
&[disabled]::before {
display: none;
}
}
.is-showing-fly-out {
.feature-highlight {
display: none;
}
}
.feature-highlight-popover-content {
display: none;
hr {
margin: $gl-padding * 0.5 0;
}
.btn-link {
svg {
@include btn-svg;
path {
fill: currentColor;
}
}
}
.feature-highlight-illustration {
width: 100%;
height: 100px;
padding-top: 12px;
padding-bottom: 12px;
background-color: $indigo-50;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
border-bottom: 1px solid darken($gray-normal, 8%);
}
}
.popover .feature-highlight-popover-content {
display: block;
}
.feature-highlight-popover {
width: 240px;
padding: 0;
border: 1px solid $dropdown-border-color;
box-shadow: 0 2px 4px $dropdown-shadow-color;
&.right > .arrow {
border-right-color: $dropdown-border-color;
}
.popover-content {
padding: 0;
}
}
.feature-highlight-popover-sub-content {
padding: 9px 14px;
}
@include keyframes(pulse-highlight) {
0% {
box-shadow: 0 0 0 0 rgba($blue-200, 0.4);
}
70% {
box-shadow: 0 0 0 10px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
...@@ -273,3 +273,16 @@ table.pipeline-project-metrics tr td { ...@@ -273,3 +273,16 @@ table.pipeline-project-metrics tr td {
border-radius: $label-border-radius; border-radius: $label-border-radius;
font-weight: $gl-font-weight-normal; font-weight: $gl-font-weight-normal;
} }
.js-groups-dropdown {
width: 100%;
}
.dropdown-group-transfer {
bottom: 100%;
top: initial;
.dropdown-content {
overflow-y: unset;
}
}
...@@ -11,7 +11,7 @@ class GroupsController < Groups::ApplicationController ...@@ -11,7 +11,7 @@ class GroupsController < Groups::ApplicationController
before_action :group, except: [:index, :new, :create] before_action :group, except: [:index, :new, :create]
# Authorize # Authorize
before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects] before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects, :transfer]
before_action :authorize_create_group!, only: [:new] before_action :authorize_create_group!, only: [:new]
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
...@@ -95,6 +95,19 @@ class GroupsController < Groups::ApplicationController ...@@ -95,6 +95,19 @@ class GroupsController < Groups::ApplicationController
redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion." redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion."
end end
def transfer
parent_group = Group.find_by(id: params[:new_parent_group_id])
service = ::Groups::TransferService.new(@group, current_user)
if service.execute(parent_group)
flash[:notice] = "Group '#{@group.name}' was successfully transferred."
redirect_to group_path(@group)
else
flash.now[:alert] = service.error
render :edit
end
end
protected protected
def authorize_create_group! def authorize_create_group!
......
...@@ -94,6 +94,19 @@ module GroupsHelper ...@@ -94,6 +94,19 @@ module GroupsHelper
end end
end end
def parent_group_options(current_group)
groups = current_user.owned_groups.sort_by(&:human_name).map do |group|
{ id: group.id, text: group.human_name }
end
groups.delete_if { |group| group[:id] == current_group.id }
groups.to_json
end
def supports_nested_groups?
Group.supports_nested_groups?
end
private private
def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false)
......
...@@ -9,6 +9,6 @@ module UserCalloutsHelper ...@@ -9,6 +9,6 @@ module UserCalloutsHelper
private private
def user_dismissed?(feature_name) def user_dismissed?(feature_name)
current_user&.callouts&.find_by(feature_name: feature_name) current_user&.callouts&.find_by(feature_name: UserCallout.feature_names[feature_name])
end end
end end
...@@ -14,7 +14,11 @@ module Storage ...@@ -14,7 +14,11 @@ module Storage
# Ensure old directory exists before moving it # Ensure old directory exists before moving it
gitlab_shell.add_namespace(repository_storage_path, full_path_was) gitlab_shell.add_namespace(repository_storage_path, full_path_was)
# Ensure new directory exists before moving it (if there's a parent)
gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent
unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path) unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path)
Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}" Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}"
# if we cannot move namespace directory we should rollback # if we cannot move namespace directory we should rollback
......
...@@ -41,7 +41,6 @@ class Namespace < ActiveRecord::Base ...@@ -41,7 +41,6 @@ class Namespace < ActiveRecord::Base
namespace_path: true namespace_path: true
validate :nesting_level_allowed validate :nesting_level_allowed
validate :allowed_path_by_redirects
delegate :name, to: :owner, allow_nil: true, prefix: true delegate :name, to: :owner, allow_nil: true, prefix: true
...@@ -54,7 +53,7 @@ class Namespace < ActiveRecord::Base ...@@ -54,7 +53,7 @@ class Namespace < ActiveRecord::Base
# Legacy Storage specific hooks # Legacy Storage specific hooks
after_update :move_dir, if: :path_changed? after_update :move_dir, if: :path_or_parent_changed?
before_destroy(prepend: true) { prepare_for_destroy } before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir after_destroy :rm_dir
...@@ -236,9 +235,12 @@ class Namespace < ActiveRecord::Base ...@@ -236,9 +235,12 @@ class Namespace < ActiveRecord::Base
end end
def full_path_was def full_path_was
return path_was unless has_parent? if parent_id_was.nil?
path_was
"#{parent.full_path}/#{path_was}" else
previous_parent = Group.find_by(id: parent_id_was)
previous_parent.full_path + '/' + path_was
end
end end
# Exports belonging to projects with legacy storage are placed in a common # Exports belonging to projects with legacy storage are placed in a common
...@@ -255,6 +257,10 @@ class Namespace < ActiveRecord::Base ...@@ -255,6 +257,10 @@ class Namespace < ActiveRecord::Base
private private
def path_or_parent_changed?
path_changed? || parent_changed?
end
def refresh_access_of_projects_invited_groups def refresh_access_of_projects_invited_groups
Group Group
.joins(project_group_links: :project) .joins(project_group_links: :project)
...@@ -291,16 +297,6 @@ class Namespace < ActiveRecord::Base ...@@ -291,16 +297,6 @@ class Namespace < ActiveRecord::Base
.update_all(share_with_group_lock: true) .update_all(share_with_group_lock: true)
end end
def allowed_path_by_redirects
return if path.nil?
errors.add(:path, "#{path} has been taken before. Please use another one") if namespace_previously_created_with_same_path?
end
def namespace_previously_created_with_same_path?
RedirectRoute.permanent.exists?(path: path)
end
def write_projects_repository_config def write_projects_repository_config
all_projects.find_each do |project| all_projects.find_each do |project|
project.expires_full_path_cache # we need to clear cache to validate renames correctly project.expires_full_path_cache # we need to clear cache to validate renames correctly
......
...@@ -63,7 +63,7 @@ class Note < ActiveRecord::Base ...@@ -63,7 +63,7 @@ class Note < ActiveRecord::Base
belongs_to :updated_by, class_name: "User" belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User' belongs_to :last_edited_by, class_name: 'User'
has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :todos
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata has_one :system_note_metadata
......
...@@ -28,6 +28,7 @@ class Todo < ActiveRecord::Base ...@@ -28,6 +28,7 @@ class Todo < ActiveRecord::Base
delegate :name, :email, to: :author, prefix: true, allow_nil: true delegate :name, :email, to: :author, prefix: true, allow_nil: true
validates :action, :project, :target_type, :user, presence: true validates :action, :project, :target_type, :user, presence: true
validates :author, presence: true
validates :target_id, presence: true, unless: :for_commit? validates :target_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit? validates :commit_id, presence: true, if: :for_commit?
......
...@@ -127,7 +127,7 @@ class User < ActiveRecord::Base ...@@ -127,7 +127,7 @@ class User < ActiveRecord::Base
has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :builds, dependent: :nullify, class_name: 'Ci::Build' # rubocop:disable Cop/ActiveRecordDependent has_many :builds, dependent: :nullify, class_name: 'Ci::Build' # rubocop:disable Cop/ActiveRecordDependent
has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' # rubocop:disable Cop/ActiveRecordDependent has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' # rubocop:disable Cop/ActiveRecordDependent
has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :todos
has_many :notification_settings, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :notification_settings, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent
......
module Groups
class TransferService < Groups::BaseService
ERROR_MESSAGES = {
database_not_supported: 'Database is not supported.',
namespace_with_same_path: 'The parent group already has a subgroup with the same path.',
group_is_already_root: 'Group is already a root group.',
same_parent_as_current: 'Group is already associated to the parent group.',
invalid_policies: "You don't have enough permissions."
}.freeze
TransferError = Class.new(StandardError)
attr_reader :error
def initialize(group, user, params = {})
super
@error = nil
end
def execute(new_parent_group)
@new_parent_group = new_parent_group
ensure_allowed_transfer
proceed_to_transfer
rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e
@group.errors.clear
@error = "Transfer failed: " + e.message
false
end
private
def proceed_to_transfer
Group.transaction do
update_group_attributes
end
end
def ensure_allowed_transfer
raise_transfer_error(:group_is_already_root) if group_is_already_root?
raise_transfer_error(:database_not_supported) unless Group.supports_nested_groups?
raise_transfer_error(:same_parent_as_current) if same_parent?
raise_transfer_error(:invalid_policies) unless valid_policies?
raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
end
def group_is_already_root?
!@new_parent_group && !@group.has_parent?
end
def same_parent?
@new_parent_group && @new_parent_group.id == @group.parent_id
end
def valid_policies?
return false unless can?(current_user, :admin_group, @group)
if @new_parent_group
can?(current_user, :create_subgroup, @new_parent_group)
else
can?(current_user, :create_group)
end
end
def namespace_with_same_path?
Namespace.exists?(path: @group.path, parent: @new_parent_group)
end
def update_group_attributes
if @new_parent_group && @new_parent_group.visibility_level < @group.visibility_level
update_children_and_projects_visibility
@group.visibility_level = @new_parent_group.visibility_level
end
@group.parent = @new_parent_group
@group.save!
end
def update_children_and_projects_visibility
descendants = @group.descendants.where("visibility_level > ?", @new_parent_group.visibility_level)
Group
.where(id: descendants.select(:id))
.update_all(visibility_level: @new_parent_group.visibility_level)
@group
.all_projects
.where("visibility_level > ?", @new_parent_group.visibility_level)
.update_all(visibility_level: @new_parent_group.visibility_level)
end
def raise_transfer_error(message)
raise TransferError, ERROR_MESSAGES[message]
end
end
end
...@@ -61,4 +61,20 @@ ...@@ -61,4 +61,20 @@
.form-actions .form-actions
= button_to 'Remove group', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_group_message(@group) } = button_to 'Remove group', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_group_message(@group) }
- if supports_nested_groups?
.panel.panel-warning
.panel-heading Transfer group
.panel-body
= form_for @group, url: transfer_group_path(@group), method: :put do |f|
.form-group
= dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: "Search groups", data: { data: parent_group_options(@group) } })
= hidden_field_tag 'new_parent_group_id'
%ul
%li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}.
%li You can only transfer the group to a group you manage.
%li You will need to update your local repositories to point to the new location.
%li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.
= f.submit 'Transfer group', class: "btn btn-warning"
= render 'shared/confirm_modal', phrase: @group.path = render 'shared/confirm_modal', phrase: @group.path
...@@ -198,10 +198,34 @@ ...@@ -198,10 +198,34 @@
Environments Environments
- if project_nav_tab? :clusters - if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do = nav_link(controller: [:clusters, :user, :gcp]) do
= link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do = link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do
%span %span
Clusters Clusters
- if show_cluster_hint
.feature-highlight.js-feature-highlight{ disabled: true,
data: { trigger: 'manual',
container: 'body',
toggle: 'popover',
placement: 'right',
highlight: UserCalloutsHelper::GKE_CLUSTER_INTEGRATION,
highlight_priority: UserCallout.feature_names[:GKE_CLUSTER_INTEGRATION],
dismiss_endpoint: user_callouts_path } }
- if show_cluster_hint
.feature-highlight-popover-content
= image_tag 'illustrations/cluster_popover.svg', class: 'feature-highlight-illustration'
.feature-highlight-popover-sub-content
%p= _('Allows you to add and manage Kubernetes clusters.')
%p
= _('Protip:')
= link_to 'Auto DevOps', help_page_path('topics/autodevops/index.md')
%span= _('uses clusters to deploy your code!')
%hr
%button.btn.btn-create.btn-xs.dismiss-feature-highlight{ type: 'button' }
%span= _("Got it!")
= sprite_icon('thumb-up')
- if @project.feature_available?(:builds, current_user) && !@project.empty_repo? - if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
= nav_link(path: 'pipelines#charts') do = nav_link(path: 'pipelines#charts') do
......
---
title: Fix the geo::db:seeds rake task.
merge_request:
author:
type: fixed
---
title: Add ability to transfer a group into another group
merge_request: 16302
author:
type: added
---
title: Add foreign key and NOT NULL constraints to todos table.
merge_request: 16849
author:
type: other
---
title: Add blue dot feature highlight to make GKE Clusters more visible to users
merge_request: 16379
author:
type: added
---
title: Added ldap config setting to lower case the username
merge_request: 16791
author:
type: added
...@@ -476,6 +476,9 @@ production: &base ...@@ -476,6 +476,9 @@ production: &base
first_name: 'givenName' first_name: 'givenName'
last_name: 'sn' last_name: 'sn'
# If lowercase_usernames is enabled, GitLab will lower case the username.
lowercase_usernames: false
# GitLab EE only: add more LDAP servers # GitLab EE only: add more LDAP servers
# Choose an ID made of a-z and 0-9 . This ID will be stored in the database # Choose an ID made of a-z and 0-9 . This ID will be stored in the database
# so that GitLab can remember which LDAP server a user belongs to. # so that GitLab can remember which LDAP server a user belongs to.
......
...@@ -180,6 +180,7 @@ if Settings.ldap['enabled'] || Rails.env.test? ...@@ -180,6 +180,7 @@ if Settings.ldap['enabled'] || Rails.env.test?
server['allow_username_or_email_login'] = false if server['allow_username_or_email_login'].nil? server['allow_username_or_email_login'] = false if server['allow_username_or_email_login'].nil?
server['active_directory'] = true if server['active_directory'].nil? server['active_directory'] = true if server['active_directory'].nil?
server['attributes'] = {} if server['attributes'].nil? server['attributes'] = {} if server['attributes'].nil?
server['lowercase_usernames'] = false if server['lowercase_usernames'].nil?
server['provider_name'] ||= "ldap#{key}".downcase server['provider_name'] ||= "ldap#{key}".downcase
server['provider_class'] = OmniAuth::Utils.camelize(server['provider_name']) server['provider_class'] = OmniAuth::Utils.camelize(server['provider_name'])
server['external_groups'] = [] if server['external_groups'].nil? server['external_groups'] = [] if server['external_groups'].nil?
......
...@@ -15,6 +15,7 @@ constraints(GroupUrlConstrainer.new) do ...@@ -15,6 +15,7 @@ constraints(GroupUrlConstrainer.new) do
get :projects, as: :projects_group get :projects, as: :projects_group
get :activity, as: :activity_group get :activity, as: :activity_group
get :subgroups, as: :subgroups_group ## EE-specific get :subgroups, as: :subgroups_group ## EE-specific
put :transfer, as: :transfer_group
end end
get '/', action: :show, as: :group_canonical get '/', action: :show, as: :group_canonical
......
class AddForeignKeysToTodos < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
class Todo < ActiveRecord::Base
self.table_name = 'todos'
include EachBatch
end
BATCH_SIZE = 1000
DOWNTIME = false
disable_ddl_transaction!
def up
Todo.where('NOT EXISTS ( SELECT true FROM users WHERE id=todos.user_id )').each_batch(of: BATCH_SIZE) do |batch|
batch.delete_all
end
Todo.where('NOT EXISTS ( SELECT true FROM users WHERE id=todos.author_id )').each_batch(of: BATCH_SIZE) do |batch|
batch.delete_all
end
Todo.where('note_id IS NOT NULL AND NOT EXISTS ( SELECT true FROM notes WHERE id=todos.note_id )').each_batch(of: BATCH_SIZE) do |batch|
batch.delete_all
end
add_concurrent_foreign_key :todos, :users, column: :user_id, on_delete: :cascade
add_concurrent_foreign_key :todos, :users, column: :author_id, on_delete: :cascade
add_concurrent_foreign_key :todos, :notes, column: :note_id, on_delete: :cascade
end
def down
remove_foreign_key :todos, :users
remove_foreign_key :todos, column: :author_id
remove_foreign_key :todos, :notes
end
end
class ChangeAuthorIdToNotNullInTodos < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
class Todo < ActiveRecord::Base
self.table_name = 'todos'
include EachBatch
end
BATCH_SIZE = 1000
DOWNTIME = false
disable_ddl_transaction!
def up
Todo.where(author_id: nil).each_batch(of: BATCH_SIZE) do |batch|
batch.delete_all
end
change_column_null :todos, :author_id, false
end
def down
change_column_null :todos, :author_id, true
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180202111106) do ActiveRecord::Schema.define(version: 20180204200836) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -2210,7 +2210,7 @@ ActiveRecord::Schema.define(version: 20180202111106) do ...@@ -2210,7 +2210,7 @@ ActiveRecord::Schema.define(version: 20180202111106) do
t.integer "project_id", null: false t.integer "project_id", null: false
t.integer "target_id" t.integer "target_id"
t.string "target_type", null: false t.string "target_type", null: false
t.integer "author_id" t.integer "author_id", null: false
t.integer "action", null: false t.integer "action", null: false
t.string "state", null: false t.string "state", null: false
t.datetime "created_at" t.datetime "created_at"
...@@ -2613,7 +2613,10 @@ ActiveRecord::Schema.define(version: 20180202111106) do ...@@ -2613,7 +2613,10 @@ ActiveRecord::Schema.define(version: 20180202111106) do
add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade
add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade
add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade
add_foreign_key "todos", "notes", name: "fk_91d1f47b13", on_delete: :cascade
add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade add_foreign_key "todos", "projects", name: "fk_45054f9c45", on_delete: :cascade
add_foreign_key "todos", "users", column: "author_id", name: "fk_ccf0373936", on_delete: :cascade
add_foreign_key "todos", "users", name: "fk_d94154aa95", on_delete: :cascade
add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade
add_foreign_key "u2f_registrations", "users" add_foreign_key "u2f_registrations", "users"
add_foreign_key "user_callouts", "users", on_delete: :cascade add_foreign_key "user_callouts", "users", on_delete: :cascade
......
...@@ -196,6 +196,10 @@ main: # 'main' is the GitLab 'provider ID' of this LDAP server ...@@ -196,6 +196,10 @@ main: # 'main' is the GitLab 'provider ID' of this LDAP server
first_name: 'givenName' first_name: 'givenName'
last_name: 'sn' last_name: 'sn'
# If lowercase_usernames is enabled, GitLab will lower case the username.
lowercase_usernames: false
## EE only ## EE only
# Base where we can search for groups # Base where we can search for groups
...@@ -399,6 +403,41 @@ your installation compares before proceeding. ...@@ -399,6 +403,41 @@ your installation compares before proceeding.
1. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect. 1. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
## Enabling LDAP username lowercase
Some LDAP servers, depending on their configurations, can return uppercase usernames. This can lead to several confusing issues like, for example, creating links or namespaces with uppercase names.
GitLab can automatically lowercase usernames provided by the LDAP server by enabling
the configuration option `lowercase_usernames`. By default, this configuration option is `false`.
**Omnibus configuration**
1. Edit `/etc/gitlab/gitlab.rb`:
```ruby
gitlab_rails['ldap_servers'] = YAML.load <<-EOS
main:
# snip...
lowercase_usernames: true
EOS
```
2. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
**Source configuration**
1. Edit `config/gitlab.yaml`:
```yaml
production:
ldap:
servers:
main:
# snip...
lowercase_usernames: true
```
2. [Restart GitLab](../restart_gitlab.md#installations-from-source) for the changes to take effect.
## Encryption ## Encryption
### TLS Server Authentication ### TLS Server Authentication
......
# Feature highlight
> [Introduced][ce-16379] in GitLab 10.5
Feature highlights are represented by a pulsing blue dot. Hovering over the dot
will open up callout with more information.
They are used to emphasize a certain feature and make something more visible to the user.
You can dismiss any feature highlight permanently by clicking the "Got it" link
at the bottom of the callout. There isn't a way to restore the feature highlight
after it has been dismissed.
![Clusters feature highlight](img/feature_highlight_example.png)
[ce-16379]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/16379
...@@ -189,6 +189,20 @@ milestones. ...@@ -189,6 +189,20 @@ milestones.
[Learn more about Epics.](epics/index.md) [Learn more about Epics.](epics/index.md)
## Transfer groups to another group
From 10.5 there are two different ways to transfer a group:
- Either by transferring a group into another group (making it a subgroup of that group).
- Or by converting a subgroup into a root group (a group with no parent).
Please make sure to understand that:
- Changing a group's parent can have unintended side effects. See [Redirects when changing repository paths](https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths)
- You can only transfer the group to a group you manage.
- You will need to update your local repositories to point to the new location.
- If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.
## Group settings ## Group settings
Once you have created a group, you can manage its settings by navigating to Once you have created a group, you can manage its settings by navigating to
......
...@@ -108,6 +108,8 @@ personal access tokens, authorized applications, etc. ...@@ -108,6 +108,8 @@ personal access tokens, authorized applications, etc.
methods available in GitLab. methods available in GitLab.
- [Permissions](permissions.md): Learn the different set of permissions levels for each - [Permissions](permissions.md): Learn the different set of permissions levels for each
user type (guest, reporter, developer, master, owner). user type (guest, reporter, developer, master, owner).
- [Feature highlight](feature_highlight.md): Learn more about the little blue dots
around the app that explain certain features
## Groups ## Groups
......
...@@ -132,8 +132,7 @@ and Git push/pull redirects. ...@@ -132,8 +132,7 @@ and Git push/pull redirects.
Depending on the situation, different things apply. Depending on the situation, different things apply.
When [transferring a project](settings/index.md#transferring-an-existing-project-into-another-namespace), When [renaming a user](../profile/index.md#changing-your-username) or
or [renaming a user](../profile/index.md#changing-your-username) or
[changing a group path](../group/index.md#changing-a-group-s-path): [changing a group path](../group/index.md#changing-a-group-s-path):
- **The redirect to the new URL is permanent**, which means that the original - **The redirect to the new URL is permanent**, which means that the original
......
...@@ -185,7 +185,7 @@ module Gitlab ...@@ -185,7 +185,7 @@ module Gitlab
class SeedLoader class SeedLoader
def load_seed def load_seed
load('db/geo/seeds.rb') load(File.join(GEO_DB_DIR, 'seeds.rb'))
end end
end end
end end
......
...@@ -7,6 +7,12 @@ module Gitlab ...@@ -7,6 +7,12 @@ module Gitlab
@uid ||= Gitlab::LDAP::Person.normalize_dn(super) @uid ||= Gitlab::LDAP::Person.normalize_dn(super)
end end
def username
super.tap do |username|
username.downcase! if ldap_config.lowercase_usernames
end
end
private private
def get_info(key) def get_info(key)
......
...@@ -153,6 +153,10 @@ module Gitlab ...@@ -153,6 +153,10 @@ module Gitlab
options['allow_username_or_email_login'] options['allow_username_or_email_login']
end end
def lowercase_usernames
options['lowercase_usernames']
end
def name_proc def name_proc
if allow_username_or_email_login if allow_username_or_email_login
proc { |name| name.gsub(/@.*\z/, '') } proc { |name| name.gsub(/@.*\z/, '') }
......
...@@ -86,7 +86,9 @@ module Gitlab ...@@ -86,7 +86,9 @@ module Gitlab
# be returned. We need only one for username. # be returned. We need only one for username.
# Ex. `uid` returns only one value but `mail` may # Ex. `uid` returns only one value but `mail` may
# return an array of multiple email addresses. # return an array of multiple email addresses.
[username].flatten.first [username].flatten.first.tap do |username|
username.downcase! if config.lowercase_usernames
end
end end
def email def email
......
...@@ -90,7 +90,7 @@ ...@@ -90,7 +90,7 @@
"worker-loader": "^1.1.0" "worker-loader": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@gitlab-org/gitlab-svgs": "^1.7.0", "@gitlab-org/gitlab-svgs": "^1.8.0",
"axios-mock-adapter": "^1.10.0", "axios-mock-adapter": "^1.10.0",
"babel-plugin-istanbul": "^4.1.5", "babel-plugin-istanbul": "^4.1.5",
"eslint": "^3.18.0", "eslint": "^3.18.0",
......
...@@ -535,4 +535,87 @@ describe GroupsController do ...@@ -535,4 +535,87 @@ describe GroupsController do
"Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path." "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path."
end end
end end
describe 'PUT transfer', :postgresql do
before do
sign_in(user)
end
context 'when transfering to a subgroup goes right' do
let(:new_parent_group) { create(:group, :public) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) }
before do
put :transfer,
id: group.to_param,
new_parent_group_id: new_parent_group.id
end
it 'should return a notice' do
expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
end
it 'should redirect to the new path' do
expect(response).to redirect_to("/#{new_parent_group.path}/#{group.path}")
end
end
context 'when converting to a root group goes right' do
let(:group) { create(:group, :public, :nested) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
before do
put :transfer,
id: group.to_param,
new_parent_group_id: ''
end
it 'should return a notice' do
expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.")
end
it 'should redirect to the new path' do
expect(response).to redirect_to("/#{group.path}")
end
end
context 'When the transfer goes wrong' do
let(:new_parent_group) { create(:group, :public) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) }
before do
allow_any_instance_of(::Groups::TransferService).to receive(:proceed_to_transfer).and_raise(Gitlab::UpdatePathError, 'namespace directory cannot be moved')
put :transfer,
id: group.to_param,
new_parent_group_id: new_parent_group.id
end
it 'should return an alert' do
expect(flash[:alert]).to eq "Transfer failed: namespace directory cannot be moved"
end
it 'should redirect to the current path' do
expect(response).to render_template(:edit)
end
end
context 'when the user is not allowed to transfer the group' do
let(:new_parent_group) { create(:group, :public) }
let!(:group_member) { create(:group_member, :guest, group: group, user: user) }
let!(:new_parent_group_member) { create(:group_member, :guest, group: new_parent_group, user: user) }
before do
put :transfer,
id: group.to_param,
new_parent_group_id: new_parent_group.id
end
it 'should be denied' do
expect(response).to have_gitlab_http_status(404)
end
end
end
end end
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import {
getSelector,
togglePopover,
dismiss,
mouseleave,
mouseenter,
inserted,
} from '~/feature_highlight/feature_highlight_helper';
import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper';
describe('feature highlight helper', () => {
describe('getSelector', () => {
it('returns js-feature-highlight selector', () => {
const highlightId = 'highlightId';
expect(getSelector(highlightId)).toEqual(`.js-feature-highlight[data-highlight=${highlightId}]`);
});
});
describe('togglePopover', () => {
describe('togglePopover(true)', () => {
it('returns true when popover is shown', () => {
const context = {
hasClass: () => false,
popover: () => {},
toggleClass: () => {},
};
expect(togglePopover.call(context, true)).toEqual(true);
});
it('returns false when popover is already shown', () => {
const context = {
hasClass: () => true,
};
expect(togglePopover.call(context, true)).toEqual(false);
});
it('shows popover', (done) => {
const context = {
hasClass: () => false,
popover: () => {},
toggleClass: () => {},
};
spyOn(context, 'popover').and.callFake((method) => {
expect(method).toEqual('show');
done();
});
togglePopover.call(context, true);
});
it('adds disable-animation and js-popover-show class', (done) => {
const context = {
hasClass: () => false,
popover: () => {},
toggleClass: () => {},
};
spyOn(context, 'toggleClass').and.callFake((classNames, show) => {
expect(classNames).toEqual('disable-animation js-popover-show');
expect(show).toEqual(true);
done();
});
togglePopover.call(context, true);
});
});
describe('togglePopover(false)', () => {
it('returns true when popover is hidden', () => {
const context = {
hasClass: () => true,
popover: () => {},
toggleClass: () => {},
};
expect(togglePopover.call(context, false)).toEqual(true);
});
it('returns false when popover is already hidden', () => {
const context = {
hasClass: () => false,
};
expect(togglePopover.call(context, false)).toEqual(false);
});
it('hides popover', (done) => {
const context = {
hasClass: () => true,
popover: () => {},
toggleClass: () => {},
};
spyOn(context, 'popover').and.callFake((method) => {
expect(method).toEqual('hide');
done();
});
togglePopover.call(context, false);
});
it('removes disable-animation and js-popover-show class', (done) => {
const context = {
hasClass: () => true,
popover: () => {},
toggleClass: () => {},
};
spyOn(context, 'toggleClass').and.callFake((classNames, show) => {
expect(classNames).toEqual('disable-animation js-popover-show');
expect(show).toEqual(false);
done();
});
togglePopover.call(context, false);
});
});
});
describe('dismiss', () => {
let mock;
const context = {
hide: () => {},
attr: () => '/-/callouts/dismiss',
};
beforeEach(() => {
mock = new MockAdapter(axios);
spyOn(togglePopover, 'call').and.callFake(() => {});
spyOn(context, 'hide').and.callFake(() => {});
dismiss.call(context);
});
afterEach(() => {
mock.restore();
});
it('calls persistent dismissal endpoint', (done) => {
const spy = jasmine.createSpy('dismiss-endpoint-hit');
mock.onPost('/-/callouts/dismiss').reply(spy);
getSetTimeoutPromise()
.then(() => {
expect(spy).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('calls hide popover', () => {
expect(togglePopover.call).toHaveBeenCalledWith(context, false);
});
it('calls hide', () => {
expect(context.hide).toHaveBeenCalled();
});
});
describe('mouseleave', () => {
it('calls hide popover if .popover:hover is false', () => {
const fakeJquery = {
length: 0,
};
spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn));
spyOn(togglePopover, 'call');
mouseleave();
expect(togglePopover.call).toHaveBeenCalledWith(jasmine.any(Object), false);
});
it('does not call hide popover if .popover:hover is true', () => {
const fakeJquery = {
length: 1,
};
spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn));
spyOn(togglePopover, 'call');
mouseleave();
expect(togglePopover.call).not.toHaveBeenCalledWith(false);
});
});
describe('mouseenter', () => {
const context = {};
it('shows popover', () => {
spyOn(togglePopover, 'call').and.returnValue(false);
mouseenter.call(context);
expect(togglePopover.call).toHaveBeenCalledWith(jasmine.any(Object), true);
});
it('registers mouseleave event if popover is showed', (done) => {
spyOn(togglePopover, 'call').and.returnValue(true);
spyOn($.fn, 'on').and.callFake((eventName) => {
expect(eventName).toEqual('mouseleave');
done();
});
mouseenter.call(context);
});
it('does not register mouseleave event if popover is not showed', () => {
spyOn(togglePopover, 'call').and.returnValue(false);
const spy = spyOn($.fn, 'on').and.callFake(() => {});
mouseenter.call(context);
expect(spy).not.toHaveBeenCalled();
});
});
describe('inserted', () => {
it('registers click event callback', (done) => {
const context = {
getAttribute: () => 'popoverId',
dataset: {
highlight: 'some-feature',
},
};
spyOn($.fn, 'on').and.callFake((event) => {
expect(event).toEqual('click');
done();
});
inserted.call(context);
});
});
});
import domContentLoaded from '~/feature_highlight/feature_highlight_options';
import bp from '~/breakpoints';
describe('feature highlight options', () => {
describe('domContentLoaded', () => {
it('should not call highlightFeatures when breakpoint is xs', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('xs');
expect(domContentLoaded()).toBe(false);
});
it('should not call highlightFeatures when breakpoint is sm', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
expect(domContentLoaded()).toBe(false);
});
it('should not call highlightFeatures when breakpoint is md', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('md');
expect(domContentLoaded()).toBe(false);
});
it('should call highlightFeatures when breakpoint is lg', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('lg');
expect(domContentLoaded()).toBe(true);
});
});
});
import * as featureHighlightHelper from '~/feature_highlight/feature_highlight_helper';
import * as featureHighlight from '~/feature_highlight/feature_highlight';
describe('feature highlight', () => {
beforeEach(() => {
setFixtures(`
<div>
<div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled>
Trigger
</div>
</div>
<div class="feature-highlight-popover-content">
Content
<div class="dismiss-feature-highlight">
Dismiss
</div>
</div>
`);
});
describe('setupFeatureHighlightPopover', () => {
const selector = '.js-feature-highlight[data-highlight=test]';
beforeEach(() => {
spyOn(window, 'addEventListener');
spyOn(window, 'removeEventListener');
featureHighlight.setupFeatureHighlightPopover('test', 0);
});
it('setup popover content', () => {
const $popoverContent = $('.feature-highlight-popover-content');
const outerHTML = $popoverContent.prop('outerHTML');
expect($(selector).data('content')).toEqual(outerHTML);
});
it('setup mouseenter', () => {
const toggleSpy = spyOn(featureHighlightHelper.togglePopover, 'call');
$(selector).trigger('mouseenter');
expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), true);
});
it('setup debounced mouseleave', (done) => {
const toggleSpy = spyOn(featureHighlightHelper.togglePopover, 'call');
$(selector).trigger('mouseleave');
// Even though we've set the debounce to 0ms, setTimeout is needed for the debounce
setTimeout(() => {
expect(toggleSpy).toHaveBeenCalledWith(jasmine.any(Object), false);
done();
}, 0);
});
it('setup inserted.bs.popover', () => {
$(selector).trigger('mouseenter');
const popoverId = $(selector).attr('aria-describedby');
const spyEvent = spyOnEvent(`#${popoverId} .dismiss-feature-highlight`, 'click');
$(`#${popoverId} .dismiss-feature-highlight`).click();
expect(spyEvent).toHaveBeenTriggered();
});
it('setup show.bs.popover', () => {
$(selector).trigger('show.bs.popover');
expect(window.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function));
});
it('setup hide.bs.popover', () => {
$(selector).trigger('hide.bs.popover');
expect(window.removeEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function));
});
it('removes disabled attribute', () => {
expect($('.js-feature-highlight').is(':disabled')).toEqual(false);
});
it('displays popover', () => {
expect($(selector).attr('aria-describedby')).toBeFalsy();
$(selector).trigger('mouseenter');
expect($(selector).attr('aria-describedby')).toBeTruthy();
});
});
describe('findHighestPriorityFeature', () => {
beforeEach(() => {
setFixtures(`
<div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
<div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
<div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
`);
});
it('should pick the highest priority feature highlight', () => {
setFixtures(`
<div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
<div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
<div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
`);
expect($('.js-feature-highlight').length).toBeGreaterThan(1);
expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
});
it('should work when no priority is set', () => {
setFixtures(`
<div class="js-feature-highlight" data-highlight="test" disabled></div>
`);
expect(featureHighlight.findHighestPriorityFeature()).toEqual('test');
});
it('should pick the highest priority feature highlight when some have no priority set', () => {
setFixtures(`
<div class="js-feature-highlight" data-highlight="test-no-priority1" disabled></div>
<div class="js-feature-highlight" data-highlight="test" data-highlight-priority="10" disabled></div>
<div class="js-feature-highlight" data-highlight="test-no-priority2" disabled></div>
<div class="js-feature-highlight" data-highlight="test-high-priority" data-highlight-priority="20" disabled></div>
<div class="js-feature-highlight" data-highlight="test-low-priority" data-highlight-priority="0" disabled></div>
`);
expect($('.js-feature-highlight').length).toBeGreaterThan(1);
expect(featureHighlight.findHighestPriorityFeature()).toEqual('test-high-priority');
});
});
describe('highlightFeatures', () => {
it('calls setupFeatureHighlightPopover', () => {
expect(featureHighlight.highlightFeatures()).toEqual('test');
});
});
});
require 'spec_helper' require 'spec_helper'
describe Gitlab::LDAP::AuthHash do describe Gitlab::LDAP::AuthHash do
include LdapHelpers
let(:auth_hash) do let(:auth_hash) do
described_class.new( described_class.new(
OmniAuth::AuthHash.new( OmniAuth::AuthHash.new(
...@@ -83,4 +85,26 @@ describe Gitlab::LDAP::AuthHash do ...@@ -83,4 +85,26 @@ describe Gitlab::LDAP::AuthHash do
end end
end end
end end
describe '#username' do
context 'if lowercase_usernames setting is' do
let(:given_uid) { 'uid=John Smith,ou=People,dc=example,dc=com' }
before do
raw_info[:uid] = ['JOHN']
end
it 'enabled the username attribute is lower cased' do
stub_ldap_config(lowercase_usernames: true)
expect(auth_hash.username).to eq 'john'
end
it 'disabled the username attribute is not lower cased' do
stub_ldap_config(lowercase_usernames: false)
expect(auth_hash.username).to eq 'JOHN'
end
end
end
end end
...@@ -139,6 +139,27 @@ describe Gitlab::LDAP::Person do ...@@ -139,6 +139,27 @@ describe Gitlab::LDAP::Person do
expect(person.username).to eq(attr_value) expect(person.username).to eq(attr_value)
end end
end end
context 'if lowercase_usernames setting is' do
let(:username_attribute) { 'uid' }
before do
entry[username_attribute] = 'JOHN'
@person = described_class.new(entry, 'ldapmain')
end
it 'enabled the username attribute is lower cased' do
stub_ldap_config(lowercase_usernames: true)
expect(@person.username).to eq 'john'
end
it 'disabled the username attribute is not lower cased' do
stub_ldap_config(lowercase_usernames: false)
expect(@person.username).to eq 'JOHN'
end
end
end end
def assert_generic_test(test_description, got, expected) def assert_generic_test(test_description, got, expected)
......
require 'spec_helper'
require Rails.root.join('db', 'migrate', '20180201110056_add_foreign_keys_to_todos.rb')
describe AddForeignKeysToTodos, :migration do
let(:todos) { table(:todos) }
let(:project) { create(:project) }
let(:user) { create(:user) }
context 'add foreign key on user_id' do
let!(:todo_with_user) { create_todo(user_id: user.id) }
let!(:todo_without_user) { create_todo(user_id: 4711) }
it 'removes orphaned todos without corresponding user' do
expect { migrate! }.to change { Todo.count }.from(2).to(1)
end
it 'does not remove entries with valid user_id' do
expect { migrate! }.not_to change { todo_with_user.reload }
end
end
context 'add foreign key on author_id' do
let!(:todo_with_author) { create_todo(author_id: user.id) }
let!(:todo_with_invalid_author) { create_todo(author_id: 4711) }
it 'removes orphaned todos by author_id' do
expect { migrate! }.to change { Todo.count }.from(2).to(1)
end
it 'does not touch author_id for valid entries' do
expect { migrate! }.not_to change { todo_with_author.reload }
end
end
context 'add foreign key on note_id' do
let(:note) { create(:note) }
let!(:todo_with_note) { create_todo(note_id: note.id) }
let!(:todo_with_invalid_note) { create_todo(note_id: 4711) }
let!(:todo_without_note) { create_todo(note_id: nil) }
it 'deletes todo if note_id is set but does not exist in notes table' do
expect { migrate! }.to change { Todo.count }.from(3).to(2)
end
it 'does not touch entry if note_id is nil' do
expect { migrate! }.not_to change { todo_without_note.reload }
end
it 'does not touch note_id for valid entries' do
expect { migrate! }.not_to change { todo_with_note.reload }
end
end
def create_todo(**opts)
todos.create!(
project_id: project.id,
user_id: user.id,
author_id: user.id,
target_type: '',
action: 0,
state: '', **opts
)
end
end
...@@ -602,4 +602,20 @@ describe Group do ...@@ -602,4 +602,20 @@ describe Group do
end end
end end
end end
describe '#has_parent?' do
context 'when the group has a parent' do
it 'should be truthy' do
group = create(:group, :nested)
expect(group.has_parent?).to be_truthy
end
end
context 'when the group has no parent' do
it 'should be falsy' do
group = create(:group, parent: nil)
expect(group.has_parent?).to be_falsy
end
end
end
end end
...@@ -704,36 +704,6 @@ describe Namespace do ...@@ -704,36 +704,6 @@ describe Namespace do
end end
end end
describe "#allowed_path_by_redirects" do
let(:namespace1) { create(:namespace, path: 'foo') }
context "when the path has been taken before" do
before do
namespace1.path = 'bar'
namespace1.save!
end
it 'should be invalid' do
namespace2 = build(:group, path: 'foo')
expect(namespace2).to be_invalid
end
it 'should return an error on path' do
namespace2 = build(:group, path: 'foo')
namespace2.valid?
expect(namespace2.errors.messages[:path].first).to eq('foo has been taken before. Please use another one')
end
end
context "when the path has not been taken before" do
it 'should be valid' do
expect(RedirectRoute.count).to eq(0)
namespace = build(:namespace)
expect(namespace).to be_valid
end
end
end
describe '#remove_exports' do describe '#remove_exports' do
let(:legacy_project) { create(:project, :with_export, namespace: namespace) } let(:legacy_project) { create(:project, :with_export, namespace: namespace) }
let(:hashed_project) { create(:project, :with_export, :hashed, namespace: namespace) } let(:hashed_project) { create(:project, :with_export, :hashed, namespace: namespace) }
...@@ -753,4 +723,44 @@ describe Namespace do ...@@ -753,4 +723,44 @@ describe Namespace do
expect(File.exist?(hashed_export)).to be_falsy expect(File.exist?(hashed_export)).to be_falsy
end end
end end
describe '#full_path_was' do
context 'when the group has no parent' do
it 'should return the path was' do
group = create(:group, parent: nil)
expect(group.full_path_was).to eq(group.path_was)
end
end
context 'when a parent is assigned to a group with no previous parent' do
it 'should return the path was' do
group = create(:group, parent: nil)
parent = create(:group)
group.parent = parent
expect(group.full_path_was).to eq("#{group.path_was}")
end
end
context 'when a parent is removed from the group' do
it 'should return the parent full path' do
parent = create(:group)
group = create(:group, parent: parent)
group.parent = nil
expect(group.full_path_was).to eq("#{parent.full_path}/#{group.path}")
end
end
context 'when changing parents' do
it 'should return the previous parent full path' do
parent = create(:group)
group = create(:group, parent: parent)
new_parent = create(:group)
group.parent = new_parent
expect(group.full_path_was).to eq("#{parent.full_path}/#{group.path}")
end
end
end
end end
...@@ -8,7 +8,7 @@ describe Note do ...@@ -8,7 +8,7 @@ describe Note do
it { is_expected.to belong_to(:noteable).touch(false) } it { is_expected.to belong_to(:noteable).touch(false) }
it { is_expected.to belong_to(:author).class_name('User') } it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to have_many(:todos).dependent(:destroy) } it { is_expected.to have_many(:todos) }
end end
describe 'modules' do describe 'modules' do
......
...@@ -20,6 +20,7 @@ describe Todo do ...@@ -20,6 +20,7 @@ describe Todo do
it { is_expected.to validate_presence_of(:action) } it { is_expected.to validate_presence_of(:action) }
it { is_expected.to validate_presence_of(:target_type) } it { is_expected.to validate_presence_of(:target_type) }
it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:author) }
context 'for commits' do context 'for commits' do
subject { described_class.new(target_type: 'Commit') } subject { described_class.new(target_type: 'Commit') }
......
...@@ -37,7 +37,7 @@ describe User do ...@@ -37,7 +37,7 @@ describe User do
it { is_expected.to have_many(:merge_requests).dependent(:destroy) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) }
it { is_expected.to have_many(:identities).dependent(:destroy) } it { is_expected.to have_many(:identities).dependent(:destroy) }
it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:spam_logs).dependent(:destroy) }
it { is_expected.to have_many(:todos).dependent(:destroy) } it { is_expected.to have_many(:todos) }
it { is_expected.to have_many(:award_emoji).dependent(:destroy) } it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
it { is_expected.to have_many(:path_locks).dependent(:destroy) } it { is_expected.to have_many(:path_locks).dependent(:destroy) }
it { is_expected.to have_many(:triggers).dependent(:destroy) } it { is_expected.to have_many(:triggers).dependent(:destroy) }
...@@ -2720,7 +2720,7 @@ describe User do ...@@ -2720,7 +2720,7 @@ describe User do
it 'should raise an ActiveRecord::RecordInvalid exception' do it 'should raise an ActiveRecord::RecordInvalid exception' do
user2 = build(:user, username: 'foo') user2 = build(:user, username: 'foo')
expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Path foo has been taken before/) expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Route path foo has been taken before. Please use another one, Route is invalid/)
end end
end end
......
This diff is collapsed.
...@@ -54,9 +54,9 @@ ...@@ -54,9 +54,9 @@
lodash "^4.2.0" lodash "^4.2.0"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@gitlab-org/gitlab-svgs@^1.7.0": "@gitlab-org/gitlab-svgs@^1.8.0":
version "1.7.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.7.0.tgz#dbb1330a1b1ee478378dddab53fe1a881e810f5d" resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.8.0.tgz#95d6afa94395860699ddad60a82bd1bbbc2ba89f"
"@types/jquery@^2.0.40": "@types/jquery@^2.0.40":
version "2.0.48" version "2.0.48"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment