Merge pull request #269 from Ducz0r/lm-sci-569

Add a refactored invite users modal that can be reused throughout application
This commit is contained in:
Luka Murn 2016-11-08 16:57:05 +01:00 committed by GitHub
commit c88f834ba9
17 changed files with 633 additions and 577 deletions

View file

@ -0,0 +1,189 @@
(function() {
'use strict';
function initializeModal(modal) {
var modalDialog = modal.find('.modal-dialog');
var type = modal.attr('data-type');
var stepForm = modal.find('[data-role=step-form]');
var stepResults = modal.find('[data-role=step-results]');
var stepResultsDiv = modal.find('[data-role=step-results][data-clear]');
var inviteBtn = modal.find('[data-role=invite-btn]');
var inviteWithRoleDiv =
modal.find('[data-role=invite-with-role-div]');
var inviteWithRoleBtn =
modal.find('[data-role=invite-with-role-btn]');
var orgSelectorCheckbox =
modal.find('[data-role=org-selector-checkbox]');
var orgSelectorDropdown =
modal.find('[data-role=org-selector-dropdown]');
var orgSelectorDropdown2 = $();
var tagsInput = modal.find('[data-role=tags-input]');
// Set max tags
tagsInput.tagsinput({
maxTags: <%= Constants::INVITE_USERS_LIMIT %>
});
modal
.on('show.bs.modal', function() {
// This cannot be scoped outside this function
// because it is generated via JS
orgSelectorDropdown2 =
orgSelectorDropdown
.next('.btn-group.bootstrap-select.form-control')
.find('button.dropdown-toggle, li');
// Show/hide correct step
stepForm.show();
stepResults.hide();
// Show/hide buttons & other elements
switch (type) {
case 'invite_to_org_with_role':
case 'invite':
case 'invite_with_org_selector':
case 'invite_with_org_selector_and_role':
inviteBtn.show();
inviteWithRoleDiv.hide();
break;
case 'invite_to_org':
inviteBtn.hide();
inviteWithRoleDiv.show();
break;
default:
break;
}
// Checkbox toggle event
if (
type === 'invite_with_org_selector' ||
type === 'invite_with_org_selector_and_role'
) {
orgSelectorCheckbox.on('change', function() {
if ($(this).is(':checked')) {
orgSelectorDropdown.removeAttr('disabled');
orgSelectorDropdown2.removeClass('disabled');
if (type === 'invite_with_org_selector') {
inviteBtn.hide();
inviteWithRoleDiv.show();
}
} else {
orgSelectorDropdown.attr('disabled', 'disabled');
orgSelectorDropdown2.addClass('disabled');
if (type === 'invite_with_org_selector') {
inviteBtn.show();
inviteWithRoleDiv.hide();
}
}
});
}
// Toggle depending on input tags
tagsInput
.on('itemAdded', function(event) {
inviteBtn.removeAttr('disabled');
inviteWithRoleBtn.removeAttr('disabled');
})
.on('itemRemoved', function(event) {
if ($(this).val() === null) {
inviteBtn.attr('disabled', 'disabled');
inviteWithRoleBtn.attr('disabled', 'disabled');
}
});
// Click action
modal.find('[data-action=invite]').on('click', function() {
animateSpinner(modalDialog);
var data = {
emails: tagsInput.val()
};
switch (type) {
case 'invite_to_org':
data.organizationId = modal.attr('data-organization-id');
data.role = $(this).attr('data-organization-role');
break;
case 'invite_to_org_with_role':
data.organizationId = modal.attr('data-organization-id');
data.role = modal.attr('data-organization-role');
break;
case 'invite':
break;
case 'invite_with_org_selector':
if (orgSelectorCheckbox.is(':checked')) {
data.organizationId = orgSelectorDropdown.val();
data.role = $(this).attr('data-organization-role');
}
break;
case 'invite_with_org_selector_and_role':
if (orgSelectorCheckbox.is(':checked')) {
data.organizationId = orgSelectorDropdown.val();
data.role = modal.attr('data-organization-role');
}
break;
default:
break;
}
$.ajax({
method: 'POST',
url: modal.attr('data-url'),
dataType: 'json',
data: data,
success: function(data) {
animateSpinner(modalDialog, false);
stepForm.hide();
stepResultsDiv.html(data.html);
stepResults.show();
// Add 'data-invited="true"' status to modal element
modal.attr('data-invited', 'true');
},
error: function() {
animateSpinner(modalDialog, false);
modal.modal('hide');
alert('Error inviting users.');
}
});
});
})
.on('shown.bs.modal', function() {
tagsInput.tagsinput('focus');
// Remove 'data-invited="true"' status
modal.removeAttr('data-invited');
})
.on('hide.bs.modal', function() {
// 'Reset' modal state
tagsInput.tagsinput('removeAll');
orgSelectorCheckbox.prop('checked', false);
inviteBtn.attr('disabled', 'disabled');
inviteWithRoleBtn.attr('disabled', 'disabled');
orgSelectorDropdown2.addClass('disabled');
animateSpinner(modalDialog, false);
// Unbind event listeners
orgSelectorCheckbox.off('change');
tagsInput.off('itemAdded itemRemoved');
// Hide contents of the results <div>
stepResultsDiv.html('');
stepResults.hide();
stepForm.show();
});
}
function initializeModalsToggle() {
$("[data-trigger='invite-users']").on('click', function() {
var id = $(this).attr('data-modal-id');
$('[data-role=invite-users-modal][data-id=' + id + ']')
.modal('show');
});
}
$(document).ready(function() {
$('[data-role=invite-users-modal]').each(function() {
initializeModal($(this));
});
initializeModalsToggle();
});
})();

View file

@ -1,5 +1,4 @@
//= require datatables
//= require users/settings/organizations/add_user_modal
var usersDatatable = null;
@ -236,8 +235,19 @@ function initRemoveUsers() {
);
}
function initReloadPageAfterInviteUsers() {
$('[data-id=org-invite-users-modal]')
.on('hidden.bs.modal', function() {
if (!_.isUndefined($(this).attr('data-invited'))) {
// Reload the whole table
usersDatatable.ajax.reload();
}
});
}
initEditName();
initEditDescription();
initUsersTable();
initUpdateRoles();
initRemoveUsers();
initRemoveUsers();
initReloadPageAfterInviteUsers();

View file

@ -1,171 +0,0 @@
/* Global selectors */
var modal = $("#add-user-modal");
var modalContent = modal.find(".modal-content");
var invitingExisting = true;
var inviteButton = $("[data-id='invite-btn']");
var inviteLinks = $("[data-action='invite']");
var inviteExistingCollapsible = $("#invite-existing");
var inviteExistingForm = $("[data-id='invite-existing-form']");
var inviteExistingQuery = $("#existing_query");
var inviteExistingResults = $("#invite-existing-results");
var inviteNewCollapsible = $("#invite-new");
var inviteNewForm = $("[data-id='invite-new-form']");
var inviteNewRoleInput = $("[data-id='new-user-role-input']");
var inviteNewNameInput = $("[data-id='invite-new-name-input']");
var inviteNewEmailInput = $("[data-id='invite-new-email-input']");
function disableInviteBtn() {
inviteButton.attr("disabled", "disabled");
}
function enableInviteBtn() {
inviteButton.removeAttr("disabled");
}
/**
* General modal configuration & toggling.
*/
modal
.on("shown.bs.modal", function() {
// Focus the invite existing input
inviteExistingQuery.focus();
invitingExisting = true;
})
.on("hidden.bs.modal", function() {
// Disable invite button,
// reset forms, reset rendered content
disableInviteBtn();
inviteExistingForm.clearFormFields();
inviteExistingForm.clearFormErrors();
inviteExistingResults.html("");
inviteNewForm.clearFormFields();
inviteNewForm.clearFormErrors();
});
inviteExistingCollapsible
.on("hidden.bs.collapse", function() {
// Reset form & rendered content
inviteExistingForm.clearFormFields();
inviteExistingForm.clearFormErrors();
inviteExistingResults.html("");
})
.on("hide.bs.collapse", function() {
// Disable invite button
disableInviteBtn();
})
.on("shown.bs.collapse", function() {
// Focus input when collapsible is shown
inviteExistingQuery.focus();
invitingExisting = true;
});
inviteNewCollapsible
.on("hidden.bs.collapse", function() {
// Reset form
inviteNewForm.clearFormFields();
inviteNewForm.clearFormErrors();
})
.on("hide.bs.collapse", function() {
// Disable invite button
disableInviteBtn();
})
.on("shown.bs.collapse", function() {
// Focus input when collapsible is shown
inviteNewNameInput.focus();
invitingExisting = false;
});
// Invite links simply submit either of the forms
inviteLinks.on("click", function() {
var $this = $(this);
if (invitingExisting) {
var form =
inviteExistingResults
.find("form[data-id='create-user-organization-form']");
// Set the role value in the form
form
.find("[data-id='existing-user-role-input']")
.attr("value", $this.attr("data-value"));
// Submit the form inside "invite existing"
animateSpinner(modalContent);
form.submit();
} else {
// Set the role value in the form
inviteNewRoleInput
.attr("value", $this.attr("data-value"));
// Submit the form inside "invite new"
animateSpinner(modalContent);
inviteNewForm.submit();
}
});
/**
* Invite existing user functionality.
*/
// Invite existing form submission
modal
.on("ajax:success", inviteExistingForm.selector, function(ev, data, status) {
// Clear form errors
inviteExistingForm.clearFormErrors();
// Alright, render the html
inviteExistingResults.html(data.html);
// Disable invite button
disableInviteBtn();
})
.on("ajax:error", inviteExistingForm.selector, function(ev, data, status) {
//Clear previous info
inviteExistingResults.html("");
// Display form errors
inviteExistingForm.renderFormErrors("", data.responseJSON);
});
// Update values & enable "invite" button
// when user clicks on existing user
inviteExistingResults
.on("change", "[data-action='select-existing-user']", function() {
var $this = $(this);
// Set the hidden input user ID
$("[data-id='existing-user-id-input']")
.attr("value", $this.attr("data-user-id"));
// Enable button
enableInviteBtn();
});
/**
* Invite new user functionality.
*/
inviteNewForm
.on("ajax:success", function(ev, data, status) {
// Reload the page
location.reload();
})
.on("ajax:error", function(ev, data, status) {
// Render form errors
animateSpinner(modalContent, false);
$(this).renderFormErrors("user", data.responseJSON);
});
// Enable/disable invite button depending whether
// any of the new user inputs are empty
inviteNewForm
.on("input", "input[data-role='input']", function() {
if (
_.isEmpty(inviteNewNameInput.val()) ||
_.isEmpty(inviteNewEmailInput.val())
) {
disableInviteBtn();
} else {
enableInviteBtn();
}
});

View file

@ -1496,3 +1496,30 @@ html.turbolinks-progress-bar::before {
width: 100px;
}
}
.modal-invite-users {
.bootstrap-tagsinput {
min-width: 450px;
}
.results-container .alert {
margin-bottom: 10px;
padding: 5px;
}
.results-container .results-wrap {
max-height: 400px;
overflow-y: auto;
padding-right: 10px;
}
.org-selector .heading {
margin-top: 15px;
margin-bottom: 5px;
input[type=checkbox] {
vertical-align: middle;
margin: 0;
}
}
}

View file

@ -1,30 +1,164 @@
class Users::InvitationsController < Devise::InvitationsController
module Users
class InvitationsController < Devise::InvitationsController
include UsersGenerator
def update
@org = Organization.new
@org.name = params[:organization][:name]
before_action :check_invite_users_permission, only: :invite_users
super do |user|
if user.errors.empty?
@org.created_by = user
@org.save
def update
@org = Organization.new
@org.name = params[:organization][:name]
UserOrganization.create(
user: user,
organization: @org,
role: 'admin'
)
super do |user|
if user.errors.empty?
@org.created_by = user
@org.save
UserOrganization.create(
user: user,
organization: @org,
role: 'admin'
)
end
end
end
end
def accept_resource
resource = super
if not @org.valid?
resource.errors.add(:base, @org.errors.to_a.first)
def accept_resource
resource = super
resource.errors.add(:base, @org.errors.to_a.first) unless @org.valid?
resource
end
resource
def invite_users
@invite_results = []
@too_many_emails = false
cntr = 0
@emails.each do |email|
cntr += 1
if cntr > Constants::INVITE_USERS_LIMIT
@too_many_emails = true
break
end
password = generate_user_password
# Check if user already exists
user = nil
user = User.find_by_email(email) if User.exists?(email: email)
result = { email: email }
if user.present?
result[:status] = :user_exists
result[:user] = user
else
# Validate the user data
error = !(Constants::BASIC_EMAIL_REGEX === email)
error = validate_user(email, email, password).count > 0 unless error
if !error
user = User.invite!(
full_name: email,
email: email,
initials: email.upcase[0..1],
skip_invitation: true
)
result[:status] = :user_created
result[:user] = user
# Sending email invitation is done in background job to prevent
# issues with email delivery. Also invite method must be call
# with :skip_invitation attribute set to true - see above.
user.delay.deliver_invitation
else
# Return invalid status
result[:status] = :user_invalid
end
end
if @org.present? && result[:status] != :user_invalid
if UserOrganization.exists?(user: user, organization: @org)
user_org =
UserOrganization.where(user: user, organization: @org).first
result[:status] = :user_exists_and_in_org
elsif result[:status] == :user_exists && !user.confirmed?
# We don't want to allow inviting unconfirmed
# users (that were not invited as part of this action)
# into organizations
result[:status] = :user_exists_unconfirmed
else
# Also generate user organization relation
user_org = UserOrganization.new(
user: user,
organization: @org,
role: @role
)
user_org.save
generate_notification(
@user,
user,
user_org.role_str,
user_org.organization
)
if result[:status] == :user_exists
result[:status] = :user_exists_invited_to_org
else
result[:status] = :user_created_invited_to_org
end
end
result[:user_org] = user_org
end
@invite_results << result
end
respond_to do |format|
format.json do
render json: {
html: render_to_string(
partial: 'shared/invite_users_modal_results.html.erb'
)
}
end
end
end
private
def generate_notification(user, target_user, role, org)
title = I18n.t('notifications.assign_user_to_organization',
assigned_user: target_user.name,
role: role,
organization: org.name,
assigned_by_user: user.name)
message = "#{I18n.t('search.index.organization')} #{org.name}"
notification = Notification.create(
type_of: :assignment,
title: ActionController::Base.helpers.sanitize(title),
message: ActionController::Base.helpers.sanitize(message)
)
if target_user.assignments_notification
UserNotification.create(notification: notification, user: target_user)
end
end
def check_invite_users_permission
@user = current_user
@emails = params[:emails]
@org = Organization.find_by_id(params['organizationId'])
@role = params['role']
render_403 if @emails && @emails.empty?
render_403 if @org && !is_admin_of_organization(@org)
render_403 if @role && !UserOrganization.roles.keys.include?(@role)
end
end
end

View file

@ -21,13 +21,7 @@ class Users::SettingsController < ApplicationController
:destroy_organization,
:organization_name,
:organization_description,
:search_organization_users,
:organization_users_datatable,
:create_user_and_user_organization
]
before_action :check_create_user_organization_permission, only: [
:create_user_organization
:organization_users_datatable
]
before_action :check_user_organization_permission, only: [
@ -118,57 +112,6 @@ class Users::SettingsController < ApplicationController
end
end
def search_organization_users
respond_to do |format|
format.json {
if params.include?(:existing_query) && params[:existing_query].strip
query = params[:existing_query].strip
if query.length < Constants::NAME_MIN_LENGTH
render json: {
"existing_query": [
t('general.query.length_too_short',
min_length: Constants::NAME_MIN_LENGTH)
]
},
status: :unprocessable_entity
elsif query.length > Constants::NAME_MAX_LENGTH
render json: {
"existing_query": [
t('general.query.length_too_long',
max_length: Constants::NAME_MAX_LENGTH)
]
},
status: :unprocessable_entity
else
# Okay, query exists and is non-blank, find users
nr_of_results = User.search(true, query, @org).count
users = User.search(false, query, @org)
.limit(Constants::MODAL_SEARCH_LIMIT)
nr_of_members = User.organization_search(false, query, @org).count
render json: {
html: render_to_string({
partial: "users/settings/organizations/existing_users_search_results.html.erb",
locals: {
users: users,
nr_of_results: nr_of_results,
nr_of_members: nr_of_members,
org: @org,
query: query
}
})
}
end
else
render json: {}, status: :bad_request
end
}
end
end
def organization_users_datatable
respond_to do |format|
format.json {
@ -213,101 +156,6 @@ class Users::SettingsController < ApplicationController
redirect_to action: :organizations
end
def create_user_organization
@new_user_org = UserOrganization.new(create_user_organization_params)
# Check if such association doesn't exist already
if !UserOrganization.where(
user: @new_user_org.user,
organization: @new_user_org.organization
).exists? && @new_user_org.save
generate_notification(@user_organization.user,
@new_user_org.user,
@new_user_org.role_str,
@new_user_org.organization)
flash[:notice] = I18n.t(
'users.settings.organizations.edit.modal_add_user.existing_flash_success',
user: @new_user_org.user.full_name,
role: @new_user_org.role_str
)
else
flash[:alert] =
I18n.t('users.settings.organizations.edit.modal_add_user.existing_flash_error')
end
# Either way, redirect back to organization page
redirect_to action: :organization,
organization_id: @new_user_org.organization_id
end
def create_user_and_user_organization
respond_to do |format|
# User & organization
# parameters are already taken care of,
# so only role needs to be verified
if !params.include? :role or
!UserOrganization.roles.keys.include? params[:role]
format.json {
render json: "Invalid role provided",
status: :unprocessable_entity
}
else
password = generate_user_password
user_params = create_user_params
full_name = user_params[:full_name]
email = user_params[:email]
# Validate the user data
errors = validate_user(full_name, email, password)
if errors.count == 0
@user = User.invite!(
full_name: full_name,
email: email,
initials: full_name.split(" ").map{|w| w[0].upcase}.join[0..3],
skip_invitation: true
)
# Sending email invitation is done in background job to prevent
# issues with email delivery. Also invite method must be call
# with :skip_invitation attribute set to true - see above.
@user.delay.deliver_invitation
# Also generate user organization relation
@user_org = UserOrganization.new(
user: @user,
organization: @org,
role: params[:role]
)
@user_org.save
# Flash message
flash[:notice] = t(
"users.settings.organizations.edit.modal_add_user.new_flash_success",
user: @user.full_name,
role: @user_org.role_str,
email: @user.email
)
flash.keep
# Return success!
format.json {
render json: {
status: :ok
}
}
else
format.json {
render json: errors,
status: :unprocessable_entity
}
end
end
end
end
def update_user_organization
respond_to do |format|
if @user_org.update(update_user_organization_params)
@ -520,13 +368,6 @@ class Users::SettingsController < ApplicationController
end
end
def check_create_user_organization_permission
@org = Organization.find_by_id(params[:user_organization][:organization_id])
unless is_admin_of_organization(@org)
render_403
end
end
def check_user_organization_permission
@user_org = UserOrganization.find_by_id(params[:user_organization_id])
@org = @user_org.organization
@ -565,41 +406,12 @@ class Users::SettingsController < ApplicationController
)
end
def create_user_organization_params
params.require(:user_organization).permit(
:user_id,
:organization_id,
:role
)
end
def update_user_organization_params
params.require(:user_organization).permit(
:role
)
end
def generate_notification(user, target_user, role, org)
title = I18n.t('notifications.assign_user_to_organization',
assigned_user: target_user.name,
role: role,
organization: org.name,
assigned_by_user: user.name)
message = "#{I18n.t('search.index.organization')} #{org.name}"
notification = Notification.create(
type_of: :assignment,
title:
ActionController::Base.helpers.sanitize(title),
message:
ActionController::Base.helpers.sanitize(message),
)
if target_user.assignments_notification
UserNotification.create(notification: notification, user: target_user)
end
end
def reset_user_current_organization(user_org)
ids = user_org.user.organizations_ids
ids -= [user_org.organization.id]

View file

@ -133,39 +133,6 @@ class User < ActiveRecord::Base
.distinct
end
# Search all active users inside given organization for
# username & email.
def self.organization_search(
active_only,
query = nil,
organization = nil
)
if !organization.present?
result = nil
else
result = User.all
if active_only
result = result.where.not(confirmed_at: nil)
end
ignored_ids =
UserOrganization
.select(:user_id)
.where(organization_id: organization.id)
result =
result
.where("users.id IN (?)", ignored_ids)
result
.where_attributes_like([:full_name, :email], query)
.distinct
end
end
def empty_avatar(name, size)
file_ext = name.split(".").last
self.avatar_file_name = name

View file

@ -0,0 +1,145 @@
<%
# How to use this modal:
# 1. Render it in the page (HTML) of your choice
# 2. Add an element (e.g. <a href>) with following attributes:
# * data-trigger="invite-users",
# * data-modal-id="modal-id",
#
# Modal parameters:
# * modal_id: unique id so the JS works if multiple modals are present
# on the same page
# * type:
# * 'invite_to_org' => params: organization
# * 'invite_to_org_with_role' => params: organization, role
# * 'invite',
# * 'invite_with_org_selector',
# * 'invite_with_org_selector_and_role' => params: role
# * organization: invite users to the specified organization
# * role: all users are invited as the specified role
# * (optional) text_title: custom title text for modal
# * (optional) text_invite_heading: custom invite heading text for modal
%>
<%
text_title ||= nil
text_invite_heading ||= nil
invite_to_org = type.in?(%w(invite_to_org invite_to_org_with_role))
%>
<div
class="modal modal-invite-users"
tabindex="-1"
role="dialog"
aria-labelledby="invite-users-modal-label"
data-id="<%= modal_id %>"
data-role="invite-users-modal"
data-type="<%= type %>"
data-url="<%= invite_users_path %>"
data-backdrop="static"
data-keyboard="false"
<%= "data-organization-id=#{organization.id}" if invite_to_org %>
<%= "data-organization-role=#{role}" if type.in?(%w(invite_to_org_with_role invite_with_org_selector_and_role)) %>
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">
<% if text_title %>
<%= text_title %>
<% else %>
<% if invite_to_org %>
<%= t('invite_users.to_org.title', organization: organization.name) %>
<% else %>
<%= t('invite_users.no_org.title') %>
<% end %>
<% end %>
</h4>
</div>
<div class="modal-body">
<div data-role="step-form">
<p>
<% if text_invite_heading %>
<%= text_invite_heading %>
<% else %>
<% if invite_to_org %>
<%= t('invite_users.to_org.heading', organization: organization.name) %>
<% else %>
<%= t('invite_users.no_org.heading') %>
<% end %>
<% end %>
</p>
<select class="emails-input" multiple data-role="tags-input" name="emails[]">
</select>
<br />
<em><%= t('invite_users.input_subtitle') %></em>
<% if type.in?(['invite_with_org_selector', 'invite_with_org_selector_and_role']) %>
<% # Only allow inviting to organizations where user is admin %>
<% uos = current_user ? current_user.user_organizations.where(role: UserOrganization.roles[:admin]).joins(:organization) : [] %>
<% if uos.count > 0 %>
<div class="org-selector">
<div class="heading">
<input type="checkbox" data-role="org-selector-checkbox" />
<span><%= t('invite_users.invite_to_org_heading') %></span>
</div>
<%= select_tag(
'organization-select',
options_for_select(
uos.pluck('organizations.name', 'organizations.id')
),
{
class: 'form-control selectpicker',
'data-role' => 'org-selector-dropdown',
disabled: 'disabled'
}
) %>
</div>
<% end %>
<% end %>
</div>
<div class="results-container" data-role="step-results" data-clear="true">
</div>
</div>
<div class="modal-footer">
<div data-role="step-form">
<button type="button" class="btn btn-default" data-dismiss="modal">
<%= t('general.cancel') %>
</button>
<!-- Invite buttons -->
<button type="button" data-role="invite-btn" class="btn btn-primary" disabled="disabled" data-action="invite">
<%= t('invite_users.invite_btn') %>
</button>
<div class="btn-group" data-role="invite-with-role-div">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" data-id="invite-btn" aria-haspopup="true" aria-expanded="false" data-role="invite-with-role-btn" disabled="disabled">
<%= t('invite_users.invite_btn') %>
&nbsp;
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li>
<%= link_to t('invite_users.invite_guest'), '#', data: { action: 'invite', 'organization-role' => 'guest' } %>
</li>
<li>
<%= link_to t('invite_users.invite_user'), '#', data: { action: 'invite', 'organization-role' => 'normal_user' } %>
</li>
<li>
<%= link_to t('invite_users.invite_admin'), '#', data: { action: 'invite', 'organization-role' => 'admin' } %>
</li>
</ul>
</div>
</div>
<div data-role="step-results">
<button type="button" class="btn btn-default" data-dismiss="modal">
<%= t('general.close') %>
</button>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,54 @@
<h5>
<%= t('invite_users.results.heading') %>
</h5>
<hr />
<div class="results-wrap">
<% @invite_results.each do |result| %>
<% if result[:status] == :user_exists %>
<div class="alert alert-info" role="alert">
<strong><%= result[:email] %></strong>
&nbsp;-&nbsp;
<%= t('invite_users.results.user_exists') %>
</div>
<% elsif result[:status] == :user_exists_unconfirmed %>
<div class="alert alert-info" role="alert">
<strong><%= result[:email] %></strong>
&nbsp;-&nbsp;
<%= t('invite_users.results.user_exists_unconfirmed', organization: @org.name) %>
</div>
<% elsif result[:status] == :user_exists_and_in_org %>
<div class="alert alert-info" role="alert">
<strong><%= result[:email] %></strong>
&nbsp;-&nbsp;
<%= t('invite_users.results.user_exists_and_in_org', organization: @org.name, role: t("user_organizations.enums.role.#{result[:user_org].role}")) %>
</div>
<% elsif result[:status] == :user_exists_invited_to_org %>
<div class="alert alert-info" role="alert">
<strong><%= result[:email] %></strong>
&nbsp;-&nbsp;
<%= t('invite_users.results.user_exists_invited_to_org', organization: @org.name, role: t("user_organizations.enums.role.#{result[:user_org].role}")) %>
</div>
<% elsif result[:status] == :user_created %>
<div class="alert alert-success" role="alert">
<strong><%= result[:email] %></strong>
&nbsp;-&nbsp;
<%= t('invite_users.results.user_created') %>
</div>
<% elsif result[:status] == :user_created_invited_to_org %>
<div class="alert alert-success" role="alert">
<strong><%= result[:email] %></strong>
&nbsp;-&nbsp;
<%= t('invite_users.results.user_created_invited_to_org', organization: @org.name, role: t("user_organizations.enums.role.#{result[:user_org].role}")) %>
</div>
<% elsif result[:status] == :user_invalid %>
<div class="alert alert-danger" role="alert">
<strong><%= result[:email] %></strong>
&nbsp;-&nbsp;
<%= t('invite_users.results.user_invalid') %>
</div>
<% end %>
<% end %>
<% if @too_many_emails %>
<%= t('invite_users.results.too_many_emails', nr: Constants::INVITE_USERS_LIMIT) %>
<% end %>
</div>

View file

@ -181,7 +181,7 @@
aria-expanded="false">
<span class="glyphicon glyphicon-info-sign"></span>
</a>
<ul class="dropdown-menu">
<ul class="dropdown-menu" data-hook="navigation-help-menu">
<li><%= link_to t('nav.help.tutorials'),
Constants::TUTORIALS_URL,
target: "_blank" %></li>
@ -217,7 +217,7 @@
<%= image_tag avatar_path(current_user, :icon_small),
class: "avatar" %>
</a>
<ul class="dropdown-menu">
<ul class="dropdown-menu" data-hook="navigation-user-menu">
<li>
<%= link_to t('nav.user.profile'), edit_user_registration_path %>
</li>

View file

@ -67,10 +67,10 @@
<%= t("users.settings.organizations.edit.manage_users") %>
</div>
<div class="panel-body">
<%= link_to "#", class: "btn btn-primary", data: { toggle: "modal", target: "#add-user-modal" } do %>
<a href="#" class="btn btn-primary" data-trigger="invite-users" data-modal-id="org-invite-users-modal">
<span class="glyphicon glyphicon-plus"></span>
<%= t("users.settings.organizations.edit.add_user") %>
<% end %>
</a>
<div class="users-datatable">
<table id="users-table" class="table" data-source="<%= organization_users_datatable_path(@org, format: :json) %>">
<thead>
@ -95,7 +95,15 @@
<%= render partial: "users/settings/organizations/name_modal.html.erb" %>
<%= render partial: "users/settings/organizations/description_modal.html.erb" %>
<%= render partial: "users/settings/organizations/add_user_modal.html.erb", locals: { org: @org } %>
<%= render(
partial: 'shared/invite_users_modal.html.erb',
locals: {
modal_id: 'org-invite-users-modal',
type: 'invite_to_org',
organization: @org
}
)
%>
<%= render partial: "users/settings/organizations/destroy_modal.html.erb", locals: { org: @org } %>
<%= render partial: "users/settings/organizations/destroy_user_organization_modal.html.erb" %>
<%= stylesheet_link_tag 'datatables' %>

View file

@ -1,92 +0,0 @@
<div class="modal" id="add-user-modal" tabindex="-1" role="dialog" aria-labelledby="add-user-modal-label">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">
<%= t("users.settings.organizations.edit.modal_add_user.title") %>
</h4>
</div>
<div class="modal-body">
<div class="panel-group" id="invite-accordion" role="tablist" aria-multiselectable="true">
<!-- Invite existing user panel -->
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="invite-existing-heading">
<h4 class="panel-title">
<a role="button" data-toggle="collapse" data-parent="#invite-accordion" href="#invite-existing" aria-expanded="true" aria-controls="invite-existing">
<%= t("users.settings.organizations.edit.modal_add_user.existing_heading") %>
</a>
</h4>
</div>
<div id="invite-existing" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="invite-existing-heading">
<div class="panel-body">
<%= bootstrap_form_tag url: search_organization_users_path(org, format: :json), remote: true, method: :get, data: { id: "invite-existing-form" } do |f| %>
<div class="form-group">
<label for="existing_query">
<%= t("users.settings.organizations.edit.modal_add_user.existing_label") %>
</label>
<div class="input-group">
<input class="form-control" type="text" placeholder="<%= t("users.settings.organizations.edit.modal_add_user.existing_placeholder") %>" value="" name="[existing_query]" id="existing_query">
<span class="input-group-btn">
<%= f.submit t("general.search"), class: "btn btn-primary" %>
</span>
</div>
</div>
<% end %>
<div id="invite-existing-results"></div>
</div>
</div>
</div>
<!-- Invite new user panel -->
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="invite-new-heading">
<h4 class="panel-title">
<a class="collapsed" role="button" data-toggle="collapse" data-parent="#invite-accordion" href="#invite-new" aria-expanded="false" aria-controls="invite-new">
<%= t("users.settings.organizations.edit.modal_add_user.new_heading") %>
</a>
</h4>
</div>
<div id="invite-new" class="panel-collapse collapse" role="tabpanel" aria-labelledby="invite-new-heading">
<div class="panel-body">
<%= bootstrap_form_for User.new, url: create_user_and_user_organization_path(format: :json), remote: true, data: { id: "invite-new-form" } do |f| %>
<input type="hidden" name="organization_id" id="organization_id" value="<%= org.id %>">
<input type="hidden" data-id="new-user-role-input" name="role" id="role" value="">
<%= f.text_field :full_name, label: t("users.settings.organizations.edit.modal_add_user.new_label_name"), placeholder: t("users.settings.organizations.edit.modal_add_user.new_placeholder_name"), data: { id: "invite-new-name-input", role: "input" } %>
<%= f.text_field :email, label: t("users.settings.organizations.edit.modal_add_user.new_label_email"), placeholder: t("users.settings.organizations.edit.modal_add_user.new_placeholder_email"), data: { id: "invite-new-email-input", role: "input" } %>
<% end %>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal"><%= t("general.cancel") %></button>
<!-- Invite button -->
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" data-id="invite-btn" aria-haspopup="true" aria-expanded="false" disabled="disabled">
<%= t("users.settings.organizations.edit.modal_add_user.invite") %>
&nbsp;
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#" data-action="invite" data-value="guest">
<%= t("users.settings.organizations.edit.modal_add_user.invite_guest") %>
</a></li>
<li><a href="#" data-action="invite" data-value="normal_user">
<%= t("users.settings.organizations.edit.modal_add_user.invite_user") %>
</a></li>
<li><a href="#" data-action="invite" data-value="admin">
<%= t("users.settings.organizations.edit.modal_add_user.invite_admin") %>
</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,36 +0,0 @@
<% if users.count > 0 %>
<%= bootstrap_form_for UserOrganization.new, url: create_user_organization_path, data: { id: "create-user-organization-form" } do %>
<input type="hidden" name="user_organization[organization_id]" id="user_organization_organization_id" value="<%= org.id %>">
<input type="hidden" data-id="existing-user-id-input" name="user_organization[user_id]" id="user_organization_user_id" value="">
<input type="hidden" data-id="existing-user-role-input" name="user_organization[role]" id="user_organization_role" value="">
<div class="btn-group-vertical btn-group-existing-users" data-toggle="buttons">
<label class="btn btn-default btn-title">
<strong><%= t("users.settings.organizations.edit.modal_add_user.existing_results_title") %></strong>
</label>
<% users.each do |user| %>
<label class="btn btn-default">
<input type="radio" name="options" data-action="select-existing-user" data-user-id="<%= user.id %>" data-id="user-result-<%= user.id %>" autocomplete="off">
<%= highlight user.full_name, query.strip.split(/\s+/) %>
</label>
<% end %>
</div>
<% if nr_of_members > 0 %>
<div class="existing-users-smalltext"><em>
<%= t("users.settings.organizations.edit.modal_add_user.existing_users_members_smalltext", count: nr_of_members) %>
</em></div>
<% end %>
<% if nr_of_results > users.count %>
<div class="existing-users-smalltext"><em>
<%= t("users.settings.organizations.edit.modal_add_user.existing_users_smalltext", count: users.count) %>
</em></div>
<% end %>
<% end %>
<% else%>
<em>
<% if nr_of_members > 0 %>
<%= t("users.settings.organizations.edit.modal_add_user.no_existing_users_members_smalltext", count: nr_of_members) %>
<% else %>
<%= t("users.settings.organizations.edit.modal_add_user.no_existing_users") %>
<% end %>
</em>
<% end %>

View file

@ -59,6 +59,7 @@ Rails.application.config.assets.precompile += %w(assets.js)
Rails.application.config.assets.precompile += %w(comments.js)
Rails.application.config.assets.precompile += %w(projects/show.js)
Rails.application.config.assets.precompile += %w(notifications.js)
Rails.application.config.assets.precompile += %w(users/invite_users_modal.js)
# Libraries needed for Handsontable formulas
Rails.application.config.assets.precompile += %w(lodash.js)

View file

@ -41,6 +41,9 @@ class Constants
# Activity limited query/display elements for pages
ACTIVITY_AND_NOTIF_SEARCH_LIMIT = 10
# Maximum number of users that can be invited in a single action
INVITE_USERS_LIMIT = 20
#=============================================================================
# File and data memory size
#=============================================================================
@ -196,6 +199,9 @@ class Constants
'text/plain'
].freeze
# Very basic regex to check for validity of emails
BASIC_EMAIL_REGEX = /^[^@]+@[^@]+\.[^@]+$/
# Organization name for default admin user
DEFAULT_PRIVATE_ORG_NAME = 'My projects'.freeze

View file

@ -1209,32 +1209,6 @@ en:
can_delete_message: "This team can be deleted because it doesn't have any projects."
delete_text: "Delete team."
cannot_delete_message_projects: "Cannot delete this team. Only empty teams (without any projects) can be deleted."
modal_add_user:
title: "Invite user to team"
existing_heading: "Invite existing sciNote user"
existing_label: "Find existing user by name or email:"
existing_placeholder: "Name or email"
existing_results_title: "Choose user"
no_existing_users: "No existing users found."
existing_users_members_smalltext:
one: "The search matched 1 user that is already member of the team."
other: "The search matched %{count} users that are already members of the team."
no_existing_users_members_smalltext:
one: "No existing users that could be invited found; the search, however, matched 1 user that is already member of the team."
other: "No existing users that could be invited found; the search, however, matched %{count} users that are already members of the team."
existing_users_smalltext: "Only showing top %{count} matched results."
existing_flash_success: "User %{user} successfully invited to team as %{role}."
existing_flash_error: "Error inviting user to team."
new_heading: "Invite new user"
new_label_name: "Type in the new user's full name:"
new_placeholder_name: "Full name"
new_label_email: "Type in the new user's email:"
new_placeholder_email: "Email"
new_flash_success: "User %{user} successfully invited to team as %{role}. Confirmation email was sent to %{email}."
invite: "Invite user"
invite_guest: "as Guest"
invite_user: "as Normal user"
invite_admin: "as Administrator"
modal_destroy_organization:
title: "Delete team"
message: "Are you sure you wish to delete team %{org}? All of the users will be removed from the team as well. This action is irreversible."
@ -1449,6 +1423,30 @@ en:
head_title: "Edit protocol"
no_keywords: "No keywords"
invite_users:
to_org:
title: "Invite users to team %{organization}"
heading: "Invite more people to team %{organization} and start using sciNote."
no_org:
title: "Invite users to sciNote"
heading: "Invite more people to start using sciNote."
input_subtitle: "Input one or multiple emails, confirm each email with ENTER key."
invite_to_org_heading: "Invite users to my team:"
invite_btn: "Invite user/s"
invite_guest: "as Guest/s"
invite_user: "as Normal user/s"
invite_admin: "as Administrator/s"
results:
heading: "Invitation results:"
user_exists: "User is already a member of sciNote."
user_exists_unconfirmed: "User is already a member of sciNote but is not confirmed yet - cannot invite to team %{organization}."
user_exists_and_in_org: "User is already a member of sciNote and team %{organization} as %{role}."
user_exists_invited_to_org: "User was already a member of sciNote - successfully invited to team %{organization} as %{role}."
user_created: "User succesfully invited to sciNote."
user_created_invited_to_org: "User successfully invited to sciNote and team %{organization} as %{role}."
user_invalid: "Invalid email."
too_many_emails: "Only invited first %{nr} emails. To invite more users, "
time:
formats:
full: "%d.%m.%Y %H:%M"

View file

@ -29,16 +29,20 @@ Rails.application.routes.draw do
put "users/settings/organizations/:organization_id", to: "users/settings#update_organization", as: "update_organization"
get "users/settings/organizations/:organization_id/name", to: "users/settings#organization_name", as: "organization_name"
get "users/settings/organizations/:organization_id/description", to: "users/settings#organization_description", as: "organization_description"
get "users/settings/organizations/:organization_id/search", to: "users/settings#search_organization_users", as: "search_organization_users"
post "users/settings/organizations/:organization_id/users_datatable", to: "users/settings#organization_users_datatable", as: "organization_users_datatable"
delete "users/settings/organizations/:organization_id", to: "users/settings#destroy_organization", as: "destroy_organization"
post "users/settings/user_organizations/new", to: "users/settings#create_user_organization", as: "create_user_organization"
post "users/settings/users_organizations/new_user", to: "users/settings#create_user_and_user_organization", as: "create_user_and_user_organization"
put "users/settings/user_organizations/:user_organization_id", to: "users/settings#update_user_organization", as: "update_user_organization"
get "users/settings/user_organizations/:user_organization_id/leave_html", to: "users/settings#leave_user_organization_html", as: "leave_user_organization_html"
get "users/settings/user_organizations/:user_organization_id/destroy_html", to: "users/settings#destroy_user_organization_html", as: "destroy_user_organization_html"
delete "users/settings/user_organizations/:user_organization_id", to: "users/settings#destroy_user_organization", as: "destroy_user_organization"
# Invite users
devise_scope :user do
post '/invite',
to: 'users/invitations#invite_users',
as: 'invite_users'
end
# Notifications
get 'users/:id/recent_notifications',
to: 'user_notifications#recent_notifications',