Merge branch 'improve_large_groups' into 'master'

Improve for large groupsImprove for large groups

This merge request is a set of patched to improve application for large groups with more then 100 people.

Pages to be improved:
 Group#members page
 Project#issues page
 Project#merge_requests page
 Project#new_issue page
 Project#new_mr page
 Project#members page
This commit is contained in:
Dmitriy Zaporozhets 2014-02-14 13:13:42 +00:00
commit f745c6e5b5
27 changed files with 347 additions and 174 deletions

View file

@ -3,6 +3,7 @@
user_path: "/api/:version/users/:id.json"
notes_path: "/api/:version/projects/:id/notes.json"
namespaces_path: "/api/:version/namespaces.json"
project_users_path: "/api/:version/projects/:id/users.json"
# Get 20 (depends on api) recent notes
# and sort the ascending from oldest to newest
@ -50,6 +51,23 @@
).done (users) ->
callback(users)
# Return project users list. Filtered by query
# Only active users retrieved
projectUsers: (project_id, query, callback) ->
url = Api.buildUrl(Api.project_users_path)
url = url.replace(':id', project_id)
$.ajax(
url: url
data:
private_token: gon.api_token
search: query
per_page: 20
active: true
dataType: "json"
).done (users) ->
callback(users)
# Return namespaces list. Filtered by query
namespaces: (query, callback) ->
url = Api.buildUrl(Api.namespaces_path)

View file

@ -0,0 +1,44 @@
$ ->
projectUserFormatResult = (user) ->
if user.avatar_url
avatar = user.avatar_url
else if gon.gravatar_enabled
avatar = gon.gravatar_url
avatar = avatar.replace('%{hash}', md5(user.email))
avatar = avatar.replace('%{size}', '24')
else
avatar = gon.relative_url_root + "/assets/no_avatar.png"
"<div class='user-result'>
<div class='user-image'><img class='avatar s24' src='#{avatar}'></div>
<div class='user-name'>#{user.name}</div>
<div class='user-username'>#{user.username}</div>
</div>"
projectUserFormatSelection = (user) ->
user.name
$('.ajax-project-users-select').each (i, select) ->
project_id = $('body').data('project-id')
$(select).select2
placeholder: $(select).data('placeholder') || "Search for a user"
multiple: $(select).hasClass('multiselect')
minimumInputLength: 0
query: (query) ->
Api.projectUsers project_id, query.term, (users) ->
data = { results: users }
query.callback(data)
initSelection: (element, callback) ->
id = $(element).val()
if id isnt ""
Api.user(id, callback)
formatResult: projectUserFormatResult
formatSelection: projectUserFormatSelection
dropdownCssClass: "ajax-project-users-dropdown"
dropdownAutoWidth: true
escapeMarkup: (m) -> # we do not want to escape markup since we are displaying html in results
m

View file

@ -1,7 +1,7 @@
$ ->
userFormatResult = (user) ->
if user.avatar
avatar = user.avatar.url
if user.avatar_url
avatar = user.avatar_url
else if gon.gravatar_enabled
avatar = gon.gravatar_url
avatar = avatar.replace('%{hash}', md5(user.email))

View file

@ -65,6 +65,7 @@
@import "sections/wall.scss";
@import "sections/dashboard.scss";
@import "sections/stat_graph.scss";
@import "sections/groups.scss";
/**
* Code ighlight

View file

@ -112,6 +112,7 @@ pre.well-pre {
.dropdown-menu > li > a:hover,
.dropdown-menu > li > a:focus {
background: #29b;
color: #FFF
}
.breadcrumb > li + li:before {

View file

@ -51,3 +51,27 @@ label {
.input-mn-300 {
min-width: 300px;
}
.custom-form-control {
width: 150px;
}
@media (min-width: $screen-sm-min) {
.custom-form-control {
width: 150px;
}
}
/* Medium devices (desktops, 992px and up) */
@media (min-width: $screen-md-min) {
.custom-form-control {
width: 170px;
}
}
/* Large devices (large desktops, 1200px and up) */
@media (min-width: $screen-lg-min) {
.custom-form-control {
width: 200px;
}
}

View file

@ -1,5 +1,4 @@
/** Select2 selectbox style override **/
.select2-container, .select2-container.select2-drop-above {
.select2-choice {
background: #FFF;
@ -12,9 +11,13 @@
}
.select2-drop-active {
border: 1px solid #BBB;
border: 1px solid #BBB !important;
margin-top: 4px;
&.select2-drop-above {
margin-bottom: 8px;
}
.select2-search input {
background: #fafafa;
border-color: #DDD;
@ -78,3 +81,9 @@ select {
.project-refs-form .select2-container {
margin-right: 10px;
}
.ajax-users-dropdown, .ajax-project-users-dropdown {
.select2-search {
padding-top: 4px;
}
}

View file

@ -0,0 +1,9 @@
.new-group-member-holder {
margin-top: 50px;
background: #f9f9f9;
padding-top: 20px;
}
.member-search-form {
float: left;
}

View file

@ -14,8 +14,8 @@
.issue-check {
float: left;
padding: 8px 0;
padding-right: 8px;
margin-bottom: 10px;
min-width: 15px;
}
@ -38,13 +38,21 @@
}
}
input.check_all_issues {
.check-all-holder {
height: 32px;
float: left;
padding: 0;
margin: 0;
margin-right: 10px;
position: relative;
top: 13px;
margin-right: 12px;
padding: 6px 10px;
border: 1px solid #ccc;
@include border-radius(4px);
input.check_all_issues {
padding: 0;
margin: 0;
position: relative;
top: 3px;
}
}
.issues_content {
@ -91,6 +99,13 @@ input.check_all_issues {
.update_selected_issues {
margin-left: 4px;
}
.select2-container .select2-choice {
height: 32px;
line-height: 28px;
color: #444 !important;
font-weight: 500;
}
}
}

View file

@ -63,7 +63,14 @@ class GroupsController < ApplicationController
def members
@project = group.projects.find(params[:project_id]) if params[:project_id]
@members = group.users_groups.order('group_access DESC')
@members = group.users_groups
if params[:search].present?
users = group.users.search(params[:search])
@members = @members.where(user_id: users)
end
@members = @members.order('group_access DESC').page(params[:page]).per(50)
@users_group = UsersGroup.new
end

View file

@ -28,7 +28,7 @@ class Projects::IssuesController < Projects::ApplicationController
@milestone = @project.milestones.find(milestone_id) if milestone_id.present? && !milestone_id.to_i.zero?
sort_param = params[:sort] || 'newest'
@sort = sort_param.humanize unless sort_param.empty?
@assignees = User.where(id: @project.issues.pluck(:assignee_id))
respond_to do |format|
format.html

View file

@ -28,6 +28,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
assignee_id, milestone_id = params[:assignee_id], params[:milestone_id]
@assignee = @project.team.find(assignee_id) if assignee_id.present? && !assignee_id.to_i.zero?
@milestone = @project.milestones.find(milestone_id) if milestone_id.present? && !milestone_id.to_i.zero?
@assignees = User.where(id: @project.merge_requests.pluck(:assignee_id))
end
def show

View file

@ -162,15 +162,6 @@ module ApplicationHelper
alias_method :url_to_image, :image_url
def users_select_tag(id, opts = {})
css_class = "ajax-users-select "
css_class << "multiselect " if opts[:multiple]
css_class << (opts[:class] || '')
value = opts[:selected] || ''
hidden_field_tag(id, value, class: css_class)
end
def body_data_page
path = controller.controller_path.split('/')
namespace = path.first if path.second

View file

@ -70,11 +70,11 @@ module IssuesHelper
end
def bulk_update_milestone_options
options_for_select(["None (backlog)", nil]) + options_from_collection_for_select(project_active_milestones, "id", "title", params[:milestone_id])
options_for_select(["None (backlog)"]) + options_from_collection_for_select(project_active_milestones, "id", "title", params[:milestone_id])
end
def bulk_update_assignee_options
options_for_select(["None (unassigned)", nil]) + options_from_collection_for_select(@project.team.members, "id", "name", params[:assignee_id])
options_for_select(["None (unassigned)"]) + options_from_collection_for_select(@project.team.members, "id", "name", params[:assignee_id])
end
def assignee_options object

View file

@ -0,0 +1,20 @@
module SelectsHelper
def users_select_tag(id, opts = {})
css_class = "ajax-users-select "
css_class << "multiselect " if opts[:multiple]
css_class << (opts[:class] || '')
value = opts[:selected] || ''
hidden_field_tag(id, value, class: css_class)
end
def project_users_select_tag(id, opts = {})
css_class = "ajax-project-users-select "
css_class << "multiselect " if opts[:multiple]
css_class << (opts[:class] || '')
value = opts[:selected] || ''
placeholder = opts[:placeholder] || 'Select user'
hidden_field_tag(id, value, class: css_class, 'data-placeholder' => placeholder)
end
end

View file

@ -1,15 +1,8 @@
= form_for @users_group, url: group_users_groups_path(@group), html: { class: 'form-horizontal users-group-form' } do |f|
%h4.append-bottom-20
New member(s) for
%strong #{@group.name}
group
%p 1. Choose users you want in the group
.form-group
= f.label :user_ids, "People", class: 'control-label'
.col-sm-10= users_select_tag(:user_ids, multiple: true, class: 'input-large')
%p 2. Set access level for them
.form-group
= f.label :group_access, "Group Access", class: 'control-label'
.col-sm-10= select_tag :group_access, options_for_select(UsersGroup.group_access_roles, @users_group.group_access), class: "project-access-select select2"

View file

@ -6,14 +6,34 @@
%strong= link_to "here", help_permissions_path, class: "vlink"
%hr
.ui-box
.clearfix
= form_tag members_group_path(@group), method: :get, class: 'form-inline member-search-form' do
.form-group
= search_field_tag :search, params[:search], { placeholder: 'Find member by name', class: 'form-control search-text-input input-mn-300' }
= submit_tag 'Search', class: 'btn'
- if current_user.can? :manage_group, @group
.pull-right
= link_to '#', class: 'btn btn-new js-toggle-visibility-link' do
Add members
%i.icon-chevron-down
.js-toggle-visibility-container.hide.new-group-member-holder
= render "new_group_member"
.ui-box.prepend-top-20
.title
%strong #{@group.name}
group members
%small
(#{@members.count})
(#{@members.total_count})
%ul.well-list
- @members.each do |member|
= render 'users_groups/users_group', member: member, show_controls: true
- if current_user.can? :manage_group, @group
= render "new_group_member"
= paginate @members, theme: 'gitlab'
:coffeescript
$('form.member-search-form').on 'submit', (event) ->
event.preventDefault()
Turbolinks.visit @.action + '?' + $(@).serialize()

View file

@ -24,7 +24,7 @@
%i.icon-user
Assign to
.col-sm-10
= f.select(:assignee_id, assignee_options(@issue), { include_blank: "Select a user" }, {class: 'select2'})
= project_users_select_tag('issue[assignee_id]', placeholder: 'Select a user', class: 'custom-form-control')
&nbsp;
= link_to 'Assign to me', '#', class: 'btn btn-small assign-to-me-link'
.form-group

View file

@ -17,10 +17,10 @@
%li.pull-right
.pull-right
= form_tag project_issues_path(@project), method: :get, id: "issue_search_form", class: 'inline issue-search-form' do
= form_tag project_issues_path(@project), method: :get, id: "issue_search_form", class: 'pull-left issue-search-form' do
.append-right-10.hidden-xs.hidden-sm
= search_field_tag :issue_search, nil, { placeholder: 'Filter by title or description', class: 'form-control issue_search search-text-input input-mn-300' }
- if can? current_user, :write_issue, @project
= link_to new_project_issue_path(@project, issue: { assignee_id: params[:assignee_id], milestone_id: params[:milestone_id]}), class: "btn btn-new", title: "New Issue", id: "new_issue_link" do
= link_to new_project_issue_path(@project, issue: { assignee_id: params[:assignee_id], milestone_id: params[:milestone_id]}), class: "btn btn-new pull-left", title: "New Issue", id: "new_issue_link" do
%i.icon-plus
New Issue

View file

@ -1,87 +1,86 @@
.ui-box
.title
.append-bottom-10
.check-all-holder
= check_box_tag "check_all_issues", nil, false, class: "check_all_issues left"
.clearfix
.issues_bulk_update.hide
= form_tag bulk_update_project_issues_path(@project), method: :post do
%span Update selected issues with &nbsp;
= select_tag('update[status]', options_for_select(['open', 'closed']), prompt: "Status")
= select_tag('update[assignee_id]', bulk_update_assignee_options, prompt: "Assignee")
= select_tag('update[milestone_id]', bulk_update_milestone_options, prompt: "Milestone")
= hidden_field_tag 'update[issues_ids]', []
= hidden_field_tag :status, params[:status]
= button_tag "Save", class: "btn update_selected_issues btn-small btn-save"
.issues-filters
%span Filter by
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"}
%i.icon-tags
%span.light labels:
- if params[:label_name].present?
%strong= params[:label_name]
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(label_name: nil) do
Any
- issue_label_names.each do |label_name|
%li
= link_to project_filter_path(label_name: label_name) do
%span{class: "label #{label_css_class(label_name)}"}
%i.icon-tag
= label_name
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"}
%i.icon-user
%span.light assignee:
- if @assignee.present?
%strong= @assignee.name
- elsif params[:assignee_id] == "0"
Unassigned
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(assignee_id: nil) do
Any
= link_to project_filter_path(assignee_id: 0) do
Unassigned
- @project.team.members.sort_by(&:name).each do |user|
%li
= link_to project_filter_path(assignee_id: user.id) do
= image_tag avatar_icon(user.email), class: "avatar s16", alt: ''
= user.name
.issues-filters
.dropdown.inline
%a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"}
%i.icon-tags
%span.light labels:
- if params[:label_name].present?
%strong= params[:label_name]
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(label_name: nil) do
Any
- issue_label_names.each do |label_name|
%li
= link_to project_filter_path(label_name: label_name) do
%span{class: "label #{label_css_class(label_name)}"}
%i.icon-tag
= label_name
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"}
%i.icon-user
%span.light assignee:
- if @assignee.present?
%strong= @assignee.name
- elsif params[:assignee_id] == "0"
Unassigned
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(assignee_id: nil) do
Any
= link_to project_filter_path(assignee_id: 0) do
Unassigned
- @assignees.sort_by(&:name).each do |user|
%li
= link_to project_filter_path(assignee_id: user.id) do
= image_tag avatar_icon(user.email), class: "avatar s16", alt: ''
= user.name
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"}
%i.icon-time
%span.light milestone:
- if @milestone.present?
%strong= @milestone.title
- elsif params[:milestone_id] == "0"
None (backlog)
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(milestone_id: nil) do
Any
= link_to project_filter_path(milestone_id: 0) do
None (backlog)
- project_active_milestones.each do |milestone|
%li
= link_to project_filter_path(milestone_id: milestone.id) do
%strong= milestone.title
%small.light= milestone.expires_at
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"}
%i.icon-time
%span.light milestone:
- if @milestone.present?
%strong= @milestone.title
- elsif params[:milestone_id] == "0"
None (backlog)
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(milestone_id: nil) do
Any
= link_to project_filter_path(milestone_id: 0) do
None (backlog)
- project_active_milestones.each do |milestone|
%li
= link_to project_filter_path(milestone_id: milestone.id) do
%strong= milestone.title
%small.light= milestone.expires_at
.pull-right
= render 'shared/sort_dropdown'
.pull-right
= render 'shared/sort_dropdown'
.clearfix
.issues_bulk_update.hide
= form_tag bulk_update_project_issues_path(@project), method: :post do
= select_tag('update[status]', options_for_select(['Open', 'Closed']), prompt: "Status")
= project_users_select_tag('update[assignee_id]', placeholder: 'Assignee')
= select_tag('update[milestone_id]', bulk_update_milestone_options, prompt: "Milestone")
= hidden_field_tag 'update[issues_ids]', []
= hidden_field_tag :status, params[:status]
= button_tag "Update issues", class: "btn update_selected_issues btn-save"
.ui-box
%ul.well-list.issues-list
= render @issues
- if @issues.blank?

View file

@ -47,7 +47,7 @@
%i.icon-user
Assign to
.col-sm-10
= f.select(:assignee_id, assignee_options(@merge_request), { include_blank: "Select a user" }, {class: 'select2'})
= project_users_select_tag('merge_request[assignee_id]', placeholder: 'Select a user', class: 'custom-form-control')
&nbsp;
= link_to 'Assign to me', '#', class: 'btn btn-small assign-to-me-link'
.form-group

View file

@ -10,59 +10,57 @@
.col-md-3
= render 'shared/project_filter', project_entities_path: project_merge_requests_path(@project)
.col-md-9
.mr-filters.append-bottom-10
.dropdown.inline
%a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"}
%i.icon-user
%span.light assignee:
- if @assignee.present?
%strong= @assignee.name
- elsif params[:assignee_id] == "0"
Unassigned
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(assignee_id: nil) do
Any
= link_to project_filter_path(assignee_id: 0) do
Unassigned
- @assignees.sort_by(&:name).each do |user|
%li
= link_to project_filter_path(assignee_id: user.id) do
= image_tag avatar_icon(user.email), class: "avatar s16", alt: ''
= user.name
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"}
%i.icon-time
%span.light milestone:
- if @milestone.present?
%strong= @milestone.title
- elsif params[:milestone_id] == "0"
None (backlog)
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(milestone_id: nil) do
Any
= link_to project_filter_path(milestone_id: 0) do
None (backlog)
- project_active_milestones.each do |milestone|
%li
= link_to project_filter_path(milestone_id: milestone.id) do
%strong= milestone.title
%small.light= milestone.expires_at
.pull-right
= render 'shared/sort_dropdown'
.ui-box
.title
.mr-filters
%span Filter by
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"}
%i.icon-user
%span.light assignee:
- if @assignee.present?
%strong= @assignee.name
- elsif params[:assignee_id] == "0"
Unassigned
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(assignee_id: nil) do
Any
= link_to project_filter_path(assignee_id: 0) do
Unassigned
- @project.team.members.sort_by(&:name).each do |user|
%li
= link_to project_filter_path(assignee_id: user.id) do
= image_tag avatar_icon(user.email), class: "avatar s16", alt: ''
= user.name
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"}
%i.icon-time
%span.light milestone:
- if @milestone.present?
%strong= @milestone.title
- elsif params[:milestone_id] == "0"
None (backlog)
- else
Any
%b.caret
%ul.dropdown-menu
%li
= link_to project_filter_path(milestone_id: nil) do
Any
= link_to project_filter_path(milestone_id: 0) do
None (backlog)
- project_active_milestones.each do |milestone|
%li
= link_to project_filter_path(milestone_id: milestone.id) do
%strong= milestone.title
%small.light= milestone.expires_at
.pull-right
= render 'shared/sort_dropdown'
%ul.well-list.mr-list
= render @merge_requests
- if @merge_requests.blank?

View file

@ -1,10 +1,14 @@
- group_users_count = @group.users_groups.count
.ui-box
.title
%strong #{@group.name}
group members (#{@group.users_groups.count})
group members (#{group_users_count})
.pull-right
= link_to members_group_path(@group), class: 'btn btn-small' do
%i.icon-edit
%ul.well-list
- @group.users_groups.order('group_access DESC').each do |member|
- @group.users_groups.order('group_access DESC').limit(20).each do |member|
= render 'users_groups/users_group', member: member, show_controls: false
- if group_users_count > 20
%li
and #{group_users_count - 20} more. For full list visit #{link_to 'group members page', members_group_path(@group)}

View file

@ -1,5 +1,5 @@
.dropdown.inline.prepend-left-10
%a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"}
%a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"}
%span.light sort:
- if @sort.present?
= @sort

View file

@ -29,6 +29,7 @@ class Groups < Spinach::FeatureSteps
And 'I select user "Mary Jane" from list with role "Reporter"' do
user = User.find_by(name: "Mary Jane") || create(:user, name: "Mary Jane")
click_link 'Add members'
within ".users-group-form" do
select2(user.id, from: "#user_ids", multiple: true)
select "Reporter", from: "group_access"

View file

@ -6,6 +6,12 @@ module API
expose :is_admin?, as: :is_admin
expose :can_create_group?, as: :can_create_group
expose :can_create_project?, as: :can_create_project
expose :avatar_url do |user, options|
if user.avatar.present?
user.avatar.url
end
end
end
class UserSafe < Grape::Entity

View file

@ -11,7 +11,7 @@ module API
end
not_found!
end
def map_public_to_visibility_level(attrs)
publik = attrs.delete(:public)
publik = [ true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON' ].include?(publik)
@ -308,6 +308,18 @@ module API
projects = Project.where("(id in (?) OR visibility_level in (?)) AND (name LIKE (?))", ids, visibility_levels, "%#{params[:query]}%")
present paginate(projects), with: Entities::Project
end
# Get a users list
#
# Example Request:
# GET /users
get ':id/users' do
@users = User.where(id: user_project.team.users.map(&:id))
@users = @users.search(params[:search]) if params[:search].present?
@users = paginate @users
present @users, with: Entities::User
end
end
end
end