Skip to content
Snippets Groups Projects
Commit 468d727a authored by Alfredo Sumaran's avatar Alfredo Sumaran
Browse files

Add support to groups in protected branch dropdown

Unselect all other roles when selecting “No one”

Update tests and handle “No one” role option

Fix "Projected" to "Protected" misspelling
parent 441d0354
No related branches found
No related tags found
1 merge request!645Restrict pushes / merges to a protected branch to specific groups
(global => {
global.gl = global.gl || {};
 
const PUSH_ACCESS_LEVEL = 'push_access_levels';
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user'
USER: 'user',
GROUP: 'group'
};
 
gl.ProtectedBranchAccessDropdown = class {
Loading
Loading
@@ -17,16 +19,24 @@
accessLevelsData
} = options;
 
this.isAllowedToPushDropdown = false;
this.groups = [];
this.accessLevel = accessLevel;
this.accessLevelsData = accessLevelsData;
this.$dropdown = $dropdown;
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.usersPath = '/autocomplete/users.json';
this.groupsPath = '/autocomplete/project_groups.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
 
this.setSelectedItems([]);
this.persistPreselectedItems();
 
if (PUSH_ACCESS_LEVEL === this.accessLevel) {
this.isAllowedToPushDropdown = true;
this.noOneObj = this.accessLevelsData[2];
}
$dropdown.glDropdown({
selectable: true,
filterable: true,
Loading
Loading
@@ -44,6 +54,31 @@
e.preventDefault();
 
if ($el.is('.is-active')) {
if (self.isAllowedToPushDropdown) {
if (item.id === self.noOneObj.id) {
// remove all others selected items
self.accessLevelsData.forEach((level) => {
if (level.id !== item.id) {
self.removeSelectedItem(level);
}
});
// remove selected item visually
self.$wrap.find(`.item-${item.type}`).removeClass(`is-active`);
} else {
$noOne = self.$wrap.find(`.is-active.item-${item.type}:contains('No one')`);
if ($noOne.length) {
$noOne.removeClass('is-active');
self.removeSelectedItem(self.noOneObj);
}
}
// make element active right away
$el.addClass(`is-active item-${item.type}`);
}
// Add "No one"
self.addSelectedItem(item);
} else {
self.removeSelectedItem(item);
Loading
Loading
@@ -104,6 +139,8 @@
obj.access_level = item.access_level
} else if (item.type === LEVEL_TYPES.USER) {
obj.user_id = item.user_id;
} else if (item.type === LEVEL_TYPES.GROUP) {
obj.group_id = item.group_id;
}
 
accessLevels.push(obj);
Loading
Loading
@@ -115,45 +152,77 @@
addSelectedItem(selectedItem) {
var itemToAdd = {};
 
// If the item already exists, just use it
let index = -1;
let selectedItems = this.getAllSelectedItems();
for (var i = 0; i < selectedItems.length; i++) {
if (selectedItem.id === selectedItems[i].access_level) {
index = i;
continue;
}
}
if (index !== -1 && selectedItems[index]._destroy) {
delete selectedItems[index]._destroy;
return;
}
itemToAdd.type = selectedItem.type;
 
if (selectedItem.type === 'user') {
if (selectedItem.type === LEVEL_TYPES.USER) {
itemToAdd = {
user_id: selectedItem.id,
name: selectedItem.name || '_name1',
username: selectedItem.username || '_username1',
avatar_url: selectedItem.avatar_url || '_avatar_url1',
type: 'user'
type: LEVEL_TYPES.USER
};
} else if (selectedItem.type === 'role') {
} else if (selectedItem.type === LEVEL_TYPES.ROLE) {
itemToAdd = {
access_level: selectedItem.id,
type: 'role'
type: LEVEL_TYPES.ROLE
}
} else if (selectedItem.type === LEVEL_TYPES.GROUP) {
itemToAdd = {
group_id: selectedItem.id,
type: LEVEL_TYPES.GROUP
}
}
this.items.push(itemToAdd);
}
 
removeSelectedItem(itemToDelete) {
let index;
let index = -1;
let selectedItems = this.getAllSelectedItems();
 
// To find itemToDelete on selectedItems, first we need the index
for (let i = 0; i < selectedItems.length; i++) {
let currentItem = selectedItems[i];
 
if (currentItem.type === 'user' &&
(currentItem.user_id === itemToDelete.id && currentItem.type === itemToDelete.type)) {
if (currentItem.type !== itemToDelete.type) {
continue;
}
if (currentItem.type === LEVEL_TYPES.USER && currentItem.user_id === itemToDelete.id) {
index = i;
} else if (currentItem.type === LEVEL_TYPES.ROLE && currentItem.access_level === itemToDelete.id) {
index = i;
} else if (currentItem.type === 'role' &&
(currentItem.access_level === itemToDelete.id && currentItem.type === itemToDelete.type)) {
} else if (currentItem.type === LEVEL_TYPES.GROUP && currentItem.group_id === itemToDelete.id) {
index = i;
}
 
if (index) { break; }
if (index > -1) { break; }
}
// if ItemToDelete is not really selected do nothing
if (index === -1) {
return;
}
 
if (selectedItems[index].persisted) {
// If we toggle an item that has been already marked with _destroy
if (selectedItems[index]._destroy) {
delete selectedItems[index]._destroy;
Loading
Loading
@@ -182,63 +251,121 @@
label.push(this.defaultLabel);
}
 
return label.join(' and ');
return label.join(', ');
}
 
getData(query, callback) {
this.getUsers(query).done((response) => {
let data = this.consolidateData(response);
this.getUsers(query).done((usersResponse) => {
if (this.groups.length) {
callback(this.consolidateData(usersResponse, this.groups));
} else {
this.getGroups(query).done((groupsResponse) => {
// Cache groups to avoid multiple requests
this.groups = groupsResponse;
callback(this.consolidateData(usersResponse, groupsResponse));
});
}
 
callback(data);
}).error(() => {
new Flash('Failed to load users.');
});
}
 
consolidateData(response, callback) {
let users;
let mergeAccessLevels;
let consolidatedData;
consolidateData(usersResponse, groupsResponse) {
let consolidatedData = [];
let map = [];
let roles = [];
let selectedUsers = [];
let unselectedUsers = [];
let groups = [];
let selectedItems = this.getSelectedItems();
 
mergeAccessLevels = this.accessLevelsData.map((level) => {
level.type = 'role';
return level;
// ID property is handled differently locally from the server
//
// For Groups
// In dropdown: `id`
// For submit: `group_id`
//
// For Roles
// In dropdown: `id`
// For submit: `access_level`
//
// For Users
// In dropdown: `id`
// For submit: `user_id`
/*
* Build groups
*/
groups = groupsResponse.map((group) => {
group.type = LEVEL_TYPES.GROUP;
return group;
});
 
let aggregate = [];
let map = [];
/*
* Build roles
*/
roles = this.accessLevelsData.map((level) => {
level.type = LEVEL_TYPES.ROLE;
return level;
});
 
/*
* Build users
*/
for (let x = 0; x < selectedItems.length; x++) {
let current = selectedItems[x];
 
if (current.type !== 'user') { continue; }
map.push(current.user_id);
if (current.type !== LEVEL_TYPES.USER) { continue; }
 
aggregate.push({
// Collect selected users
selectedUsers.push({
id: current.user_id,
name: current.name,
username: current.username,
avatar_url: current.avatar_url,
type: 'user'
type: LEVEL_TYPES.USER
});
// Save identifiers for easy-checking more later
map.push(LEVEL_TYPES.USER + current.user_id);
}
 
for (let i = 0; i < response.length; i++) {
let x = response[i];
// Has to be checked against server response
// because the selected item can be in filter results
for (let i = 0; i < usersResponse.length; i++) {
let u = usersResponse[i];
 
// Add is it has not been added
if (map.indexOf(x.id) === -1){
x.type = 'user';
aggregate.push(x);
if (map.indexOf(LEVEL_TYPES.USER + u.id) === -1){
u.type = LEVEL_TYPES.USER;
unselectedUsers.push(u);
}
}
if (groups.length) {
consolidatedData =consolidatedData.concat(groups);
}
if (roles.length) {
if (groups.length) {
consolidatedData = consolidatedData.concat(['divider']);
}
consolidatedData = consolidatedData.concat(roles);
}
 
consolidatedData = mergeAccessLevels;
if (selectedUsers.length) {
consolidatedData = consolidatedData.concat(['divider'], selectedUsers);
}
 
if (aggregate.length) {
consolidatedData = mergeAccessLevels.concat(['divider'], aggregate);
if (unselectedUsers.length) {
if (!selectedUsers.length) {
consolidatedData = consolidatedData.concat(['divider']);
}
consolidatedData = consolidatedData.concat(unselectedUsers);
}
 
return consolidatedData;
Loading
Loading
@@ -258,6 +385,16 @@
});
}
 
getGroups(query) {
return $.ajax({
dataType: 'json',
url: this.buildUrl(this.groupsPath),
data: {
project_id: gon.current_project_id
}
});
}
buildUrl(url) {
if (gon.relative_url_root != null) {
url = gon.relative_url_root.replace(/\/$/, '') + url;
Loading
Loading
@@ -271,18 +408,22 @@
 
// Dectect if the current item is already saved so we can add
// the `is-active` class so the item looks as marked
if (item.type === 'user') {
if (item.type === LEVEL_TYPES.USER) {
criteria = { user_id: item.id };
} else if (item.type === 'role') {
} else if (item.type === LEVEL_TYPES.ROLE) {
criteria = { access_level: item.id };
} else if (item.type === LEVEL_TYPES.GROUP) {
criteria = { group_id: item.id };
}
 
isActive = _.findWhere(this.getSelectedItems(), criteria) ? 'is-active' : '';
 
if (item.type === 'user') {
if (item.type === LEVEL_TYPES.USER) {
return this.userRowHtml(item, isActive);
} else if (item.type === 'role') {
} else if (item.type === LEVEL_TYPES.ROLE) {
return this.roleRowHtml(item, isActive);
} else if (item.type === LEVEL_TYPES.GROUP) {
return this.groupRowHtml(item, isActive);
}
}
 
Loading
Loading
@@ -293,8 +434,15 @@
return `<li><a href='#' class='${isActive ? 'is-active' : ''}'>${avatarHtml} ${nameHtml} ${usernameHtml}</a></li>`;
}
 
groupRowHtml(group, isActive) {
const avatarHtml = group.avatar_url ? `<img src='${group.avatar_url}' class='avatar avatar-inline' width='30'>` : '';
const nameHtml = `<strong class='dropdown-menu-group-full-name'>${group.name}</strong>`;
const groupnameHtml = `<span class='dropdown-menu-group-groupname'>${group.name}</span>`;
return `<li><a href='#' class='${isActive ? 'is-active' : ''}'>${avatarHtml} ${nameHtml} ${groupnameHtml}</a></li>`;
}
roleRowHtml(role, isActive) {
return `<li><a href='#' class='${isActive ? 'is-active' : ''}'>${role.text}</a></li>`;
return `<li><a href='#' class='${isActive ? 'is-active' : ''} item-${role.type}'>${role.text}</a></li>`;
}
}
 
Loading
Loading
Loading
Loading
@@ -6,6 +6,12 @@
PUSH: 'push_access_levels',
};
 
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchCreate = class {
constructor() {
this.$wrap = this.$form = $('#new_protected_branch');
Loading
Loading
@@ -72,14 +78,18 @@
for (let i = 0; i < selectedItems.length; i++) {
let current = selectedItems[i];
 
if (current.type === 'user') {
if (current.type === LEVEL_TYPES.USER) {
levelAttributes.push({
user_id: selectedItems[i].user_id
});
} else if (current.type === 'role') {
} else if (current.type === LEVEL_TYPES.ROLE) {
levelAttributes.push({
access_level: selectedItems[i].access_level
});
} else if (current.type === LEVEL_TYPES.GROUP) {
levelAttributes.push({
group_id: selectedItems[i].group_id
});
}
}
 
Loading
Loading
Loading
Loading
@@ -6,6 +6,12 @@
PUSH: 'push_access_levels',
};
 
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchEdit = class {
constructor(options) {
this.$wraps = {};
Loading
Loading
@@ -21,6 +27,7 @@
}
 
buildDropdowns() {
// Allowed to merge dropdown
this['merge_access_levels_dropdown'] = new gl.ProtectedBranchAccessDropdown({
accessLevel: ACCESS_LEVELS.MERGE,
Loading
Loading
@@ -95,25 +102,33 @@
let currentItem = items[i];
 
if (currentItem.user_id) {
// Solo haciendo esto solo para usuarios por ahora
// obtenemos la data más actual de los items seleccionados
// Do this only for users for now
// get the current data for selected items
let selectedItems = this[dropdownName].getSelectedItems();
let currentSelectedItem = _.findWhere(selectedItems, { user_id: currentItem.user_id });
 
itemToAdd = {
id: currentItem.id,
user_id: currentItem.user_id,
type: 'user',
type: LEVEL_TYPES.USER,
persisted: true,
name: currentSelectedItem.name,
username: currentSelectedItem.username,
avatar_url: currentSelectedItem.avatar_url
}
} else if (currentItem.group_id) {
itemToAdd = {
id: currentItem.id,
group_id: currentItem.group_id,
type: LEVEL_TYPES.GROUP,
persisted: true
};
} else {
itemToAdd = {
id: currentItem.id,
access_level: currentItem.access_level,
type: 'role',
type: LEVEL_TYPES.ROLE,
persisted: true
}
}
Loading
Loading
// Modified version of `UsersSelect` for use with access selection for protected branches.
//
// - Selections are sent via AJAX if `saveOnSelect` is `true`
// - If `saveOnSelect` is `false`, the dropdown element must have a `field-name` data
// attribute. The DOM must contain two fields - "#{field-name}[access_level]" and "#{field_name}[user_id]"
// where the selections will be stored.
class ProtectedBranchesAccessSelect {
constructor(container, saveOnSelect, selectDefault) {
this.container = container;
this.saveOnSelect = saveOnSelect;
this.selectDefault = selectDefault;
this.usersPath = "/autocomplete/users.json";
this.setupDropdown(".allowed-to-merge", gon.merge_access_levels, gon.selected_merge_access_levels);
this.setupDropdown(".allowed-to-push", gon.push_access_levels, gon.selected_push_access_levels);
}
setupDropdown(className, accessLevels, selectedAccessLevels) {
this.container.find(className).each((i, element) => {
var dropdown = $(element).glDropdown({
clicked: _.chain(this.onSelect).partial(element).bind(this).value(),
data: (term, callback) => {
this.getUsers(term, (users) => {
users = _(users).map((user) => _(user).extend({ type: "user" }));
accessLevels = _(accessLevels).map((accessLevel) => _(accessLevel).extend({ type: "role" }));
var accessLevelsWithUsers = accessLevels.concat("divider", users);
callback(_(accessLevelsWithUsers).reject((item) => _.contains(selectedAccessLevels, item.id)));
});
},
filterable: true,
filterRemote: true,
search: { fields: ['name', 'username'] },
selectable: true,
toggleLabel: (selected) => $(element).data('default-label'),
renderRow: (user) => {
if (user.before_divider != null) {
return "<li> <a href='#'>" + user.text + " </a> </li>";
}
var username = user.username ? "@" + user.username : null;
var avatar = user.avatar_url ? user.avatar_url : false;
var img = avatar ? "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />" : '';
var listWithName = "<li> <a href='#' class='dropdown-menu-user-link'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
var listWithUserName = username ? "<span class='dropdown-menu-user-username'> " + username + " </span>" : '';
var listClosingTags = "</a> </li>";
return listWithName + listWithUserName + listClosingTags;
}
});
if (this.selectDefault) {
$(dropdown).find('.dropdown-toggle-text').text(accessLevels[0].text);
}
});
}
onSelect(dropdown, selected, element, e) {
$(dropdown).find('.dropdown-toggle-text').text(selected.text || selected.name);
var access_level = selected.type == 'user' ? 40 : selected.id;
var user_id = selected.type == 'user' ? selected.id : null;
if (this.saveOnSelect) {
$.ajax({
type: "POST",
url: $(dropdown).data('url'),
dataType: "json",
data: {
_method: 'PATCH',
id: $(dropdown).data('id'),
protected_branch: {
["" + ($(dropdown).data('type')) + "_attributes"]: [{
access_level: access_level,
user_id: user_id
}]
}
},
success: function() {
var row;
row = $(e.target);
row.closest('tr').effect('highlight');
row.closest('td').find('.access-levels-list').append("<li>" + selected.name + "</li>");
location.reload();
},
error: function() {
new Flash("Failed to update branch!", "alert");
}
});
} else {
var fieldName = $(dropdown).data('field-name');
$("input[name='" + fieldName + "[access_level]']").val(access_level);
$("input[name='" + fieldName + "[user_id]']").val(user_id);
}
}
getUsers(query, callback) {
var url = this.buildUrl(this.usersPath);
return $.ajax({
url: url,
data: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true
},
dataType: "json"
}).done(function(users) {
callback(users);
});
}
buildUrl(url) {
if (gon.relative_url_root != null) {
url = gon.relative_url_root.replace(/\/$/, '') + url;
}
return url;
}
}
Loading
Loading
@@ -41,6 +41,8 @@ def access_levels_data(access_levels)
name: level.user.name,
avatar_url: level.user.avatar_url
}
elsif level.type == :group
{ id: level.id, type: level.type, group_id: level.group_id }
else
{ id: level.id, type: level.type, access_level: level.access_level }
end
Loading
Loading
Loading
Loading
@@ -2,6 +2,7 @@
[['merge', ProtectedBranch::MergeAccessLevel], ['push', ProtectedBranch::PushAccessLevel]].each do |git_operation, access_level_class|
# Need to set a default for the `git_operation` access level that _isn't_ being tested
other_git_operation = git_operation == 'merge' ? 'push' : 'merge'
roles = git_operation == 'merge' ? access_level_class.human_access_levels : access_level_class.human_access_levels.except(0)
 
let(:users) { create_list(:user, 5) }
let(:groups) { create_list(:group, 5) }
Loading
Loading
@@ -12,8 +13,6 @@
end
 
it "allows creating protected branches that roles, users, and groups can #{git_operation} to" do
roles = access_level_class.human_access_levels
visit namespace_project_protected_branches_path(project.namespace, project)
 
set_protected_branch_name('master')
Loading
Loading
@@ -32,8 +31,6 @@
end
 
it "allows updating protected branches so that roles and users can #{git_operation} to it" do
roles = access_level_class.human_access_levels
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
set_allowed_to('merge')
Loading
Loading
@@ -42,7 +39,6 @@
click_on "Protect"
 
within(".js-protected-branch-edit-form") do
set_allowed_to(git_operation, users.map(&:name))
set_allowed_to(git_operation, groups.map(&:name))
set_allowed_to(git_operation, roles.values)
Loading
Loading
@@ -57,8 +53,6 @@
end
 
it "allows updating protected branches so that roles and users cannot #{git_operation} to it" do
roles = access_level_class.human_access_levels
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
 
Loading
Loading
@@ -84,7 +78,6 @@
it "prepends selected users that can #{git_operation} to" do
users = create_list(:user, 21)
users.each { |user| project.team << [user, :developer] }
roles = access_level_class.human_access_levels
 
visit namespace_project_protected_branches_path(project.namespace, project)
 
Loading
Loading
@@ -103,7 +96,6 @@
click_on users.last.name
find(".js-allowed-to-#{git_operation}").click # close
end
wait_for_ajax
 
# Verify the user is appended in the dropdown
Loading
Loading
@@ -115,4 +107,48 @@
expect(ProtectedBranch.last.send("#{git_operation}_access_levels".to_sym).map(&:user_id)).to include(users.last.id)
end
end
context 'When updating a protected branch' do
it 'discards other roles when choosing "No one"' do
roles = ProtectedBranch::PushAccessLevel.human_access_levels.except(0)
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('fix')
set_allowed_to('merge')
set_allowed_to('push', roles.values)
click_on "Protect"
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id)
end
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).not_to include(0)
within(".js-protected-branch-edit-form") do
set_allowed_to('push', 'No one')
end
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).not_to include(access_type_id)
end
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(0)
end
end
context 'When creating a protected branch' do
it 'discards other roles when choosing "No one"' do
roles = ProtectedBranch::PushAccessLevel.human_access_levels.except(0)
visit namespace_project_protected_branches_path(project.namespace, project)
set_protected_branch_name('master')
set_allowed_to('merge')
set_allowed_to('push', ProtectedBranch::PushAccessLevel.human_access_levels.values) # Last item (No one) should deselect the other ones
click_on "Protect"
wait_for_ajax
roles.each do |(access_type_id, _)|
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).not_to include(access_type_id)
end
expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(0)
end
end
end
require 'spec_helper'
Dir["./spec/features/protected_branches/*.rb"].sort.each { |f| require f }
 
feature 'Projected Branches', feature: true, js: true do
feature 'Protected Branches', feature: true, js: true do
include WaitForAjax
 
let(:user) { create(:user, :admin) }
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment