Commit 468d727a authored by Alfredo Sumaran's avatar Alfredo Sumaran

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
(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 {
......@@ -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,
......@@ -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);
......@@ -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);
......@@ -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;
......@@ -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;
......@@ -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;
......@@ -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);
}
}
......@@ -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>`;
}
}
......
......@@ -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');
......@@ -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
});
}
}
......
......@@ -6,6 +6,12 @@
PUSH: 'push_access_levels',
};
const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group'
};
gl.ProtectedBranchEdit = class {
constructor(options) {
this.$wraps = {};
......@@ -21,6 +27,7 @@
}
buildDropdowns() {
// Allowed to merge dropdown
this['merge_access_levels_dropdown'] = new gl.ProtectedBranchAccessDropdown({
accessLevel: ACCESS_LEVELS.MERGE,
......@@ -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
}
}
......
// 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;
}
}
......@@ -41,6 +41,8 @@ module BranchesHelper
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
......
......@@ -2,6 +2,7 @@ RSpec.shared_examples "protected branches > access control > EE" do
[['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) }
......@@ -12,8 +13,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
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')
......@@ -32,8 +31,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
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')
......@@ -42,7 +39,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
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)
......@@ -57,8 +53,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
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')
......@@ -84,7 +78,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
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)
......@@ -103,7 +96,6 @@ RSpec.shared_examples "protected branches > access control > EE" do
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
......@@ -115,4 +107,48 @@ RSpec.shared_examples "protected branches > access control > EE" do
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) }
......
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