Allow groups to be used as aliases for user mention

when configured by the admin a group can be found through the @mentions
feature in both the compose/reply and the private message user-selectors
and once selected the mention will be replaced by the list of users in
the group
This commit is contained in:
Benjamin Kampmann 2013-12-23 15:46:00 +01:00
parent fd36fa1c2f
commit c743a985a4
15 changed files with 154 additions and 39 deletions

View File

@ -35,6 +35,16 @@ Discourse.Group = Discourse.Model.extend({
return usernames;
}.property('users'),
validValues: function() {
return Em.A([
{ name: I18n.t("admin.groups.alias_levels.nobody"), value: 0},
{ name: I18n.t("admin.groups.alias_levels.only_admins"), value: 1},
{ name: I18n.t("admin.groups.alias_levels.mods_and_admins"), value: 2},
{ name: I18n.t("admin.groups.alias_levels.members_mods_and_admins"), value: 3},
{ name: I18n.t("admin.groups.alias_levels.everyone"), value: 99}
]);
}.property(),
destroy: function(){
if(!this.id) return;
@ -58,6 +68,7 @@ Discourse.Group = Discourse.Model.extend({
return Discourse.ajax("/admin/groups", {type: "POST", data: {
group: {
name: this.get('name'),
alias_level: this.get('alias_level'),
usernames: this.get('usernames')
}
}}).then(function(resp) {
@ -83,6 +94,7 @@ Discourse.Group = Discourse.Model.extend({
data: {
group: {
name: this.get('name'),
alias_level: this.get('alias_level'),
usernames: this.get('usernames')
}
},

View File

@ -24,15 +24,23 @@
{{textField value=name placeholderKey="admin.groups.name_placeholder"}}
{{/if}}
{{userSelector usernames=usernames id="group-users" placeholderKey="admin.groups.selector_placeholder" tabindex="1" disabledBinding="automatic"}}
<div class="control-group">
<label class="control-label">Group members
</label>
<div class="controls">
{{userSelector usernames=usernames id="group-users" placeholderKey="admin.groups.selector_placeholder" tabindex="1" disabledBinding="automatic"}}
</div>
</div>
<div class="control-group">
<label class="control-label">{{i18n admin.groups.alias_levels.title}}</label>
<div class="controls">
{{combobox valueAttribute="value" value=alias_level content=validValues}}
</div>
</div>
<div class='controls'>
<button {{action save this}} {{bindAttr disabled="disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
{{#unless automatic}}
<button {{action save this}} {{bindAttr disabled="disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
{{#if id}}
<a {{action destroy this}} class='delete-link'>{{i18n admin.customize.delete}}</a>
{{/if}}
{{else}}
{{i18n admin.groups.can_not_edit_automatic}}
{{/unless}}
</div>
{{/with}}

View File

@ -92,19 +92,26 @@ $.fn.autocomplete = function(options) {
// dump what we have in single mode, just in case
inputSelectedItems = [];
}
var d = $("<div class='item'><span>" + (transformed || item) + "<a class='remove' href='#'><i class='fa fa-times'></i></a></span></div>");
var prev = me.parent().find('.item:last');
if (prev.length === 0) {
me.parent().prepend(d);
} else {
prev.after(d);
if (!_.isArray(transformed)) {
transformed = [transformed || item];
}
inputSelectedItems.push(item);
var divs = transformed.map(function(itm) {
var d = $("<div class='item'><span>" + (itm) + "<a href='#'><i class='fa fa-times'></i></a></span></div>");
var prev = me.parent().find('.item:last');
if (prev.length === 0) {
me.parent().prepend(d);
} else {
prev.after(d);
}
inputSelectedItems.push(itm);
return divs;
});
if (options.onChangeItems) {
options.onChangeItems(inputSelectedItems);
}
d.find('a').click(function() {
$(divs).find('a').click(function() {
closeAutocomplete();
inputSelectedItems.splice($.inArray(item, inputSelectedItems), 1);
$(this).parent().parent().remove();

View File

@ -9,11 +9,12 @@ var cache = {};
var cacheTopicId = null;
var cacheTime = null;
var debouncedSearch = Discourse.debouncePromise(function(term, topicId) {
var debouncedSearch = Discourse.debouncePromise(function(term, topicId, include_groups) {
return Discourse.ajax('/users/search/users', {
data: {
term: term,
topic_id: topicId
topic_id: topicId,
include_groups: include_groups
}
}).then(function (r) {
cache[term] = r;
@ -26,6 +27,7 @@ Discourse.UserSearch = {
search: function(options) {
var term = options.term || "";
var include_groups = options.include_groups || false;
var exclude = options.exclude || [];
var topicId = options.topicId;
var limit = options.limit || 5;
@ -46,21 +48,35 @@ Discourse.UserSearch = {
cacheTopicId = topicId;
var organizeResults = function(r) {
var result = [];
var users = [], groups = [], results = [];
_.each(r.users,function(u) {
if (exclude.indexOf(u.username) === -1) {
result.push(u);
users.push(u);
results.push(u);
}
if (result.length > limit) return false;
if (results.length > limit) return false;
return true;
});
promise.resolve(result);
_.each(r.groups,function(g) {
if (results.length > limit) return false;
if (exclude.indexOf(g.name) === -1) {
groups.push(g);
results.push(g);
}
return true;
});
results.users = users;
results.groups = groups;
promise.resolve(results);
};
if (cache[term]) {
organizeResults(cache[term]);
} else {
debouncedSearch(term, topicId).then(organizeResults);
debouncedSearch(term, topicId, include_groups).then(organizeResults);
}
return promise;
}

View File

@ -28,6 +28,7 @@
{{userSelector topicId=controller.controllers.topic.model.id
excludeCurrentUser="true"
id="private-message-users"
include_groups="true"
class="span8"
placeholderKey="composer.users_placeholder"
tabindex="1"

View File

@ -10,7 +10,7 @@
{{i18n topic.invite_private.success}}
{{else}}
<label>{{i18n topic.invite_private.email_or_username}}</label>
{{userSelector single=true allowAny=true usernames=emailOrUsername placeholderKey="topic.invite_private.email_or_username_placeholder"}}
{{userSelector single=true allowAny=true usernames=emailOrUsername include_groups="true" placeholderKey="topic.invite_private.email_or_username_placeholder"}}
{{/if}}
</div>
<div class="modal-footer">

View File

@ -183,11 +183,18 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
dataSource: function(term) {
return Discourse.UserSearch.search({
term: term,
topicId: composerView.get('controller.controllers.topic.model.id')
topicId: composerView.get('controller.controllers.topic.model.id'),
include_groups: true
});
},
key: "@",
transformComplete: function(v) { return v.username; }
transformComplete: function(v) {
if (v.username) {
return v.username;
} else {
return v.usernames.join(", @");
}
}
});
this.editor = editor = Discourse.Markdown.createEditor({

View File

@ -4,7 +4,6 @@ Discourse.UserSelector = Discourse.TextField.extend({
var userSelectorView = this;
var selected = [];
var transformTemplate = Handlebars.compile("{{avatar this imageSize=\"tiny\"}} {{this.username}}");
$(this.get('element')).val(this.get('usernames')).autocomplete({
template: Discourse.UserSelector.templateFunction(),
@ -20,9 +19,17 @@ Discourse.UserSelector = Discourse.TextField.extend({
return Discourse.UserSearch.search({
term: term,
topicId: userSelectorView.get('topicId'),
exclude: exclude
exclude: exclude,
include_groups: userSelectorView.get('include_groups')
});
},
transformComplete: function(v) {
if (v.username) {
return v.username;
} else {
return v.usernames;
}
},
onChangeItems: function(items) {
items = _.map(items, function(i) {
@ -36,8 +43,6 @@ Discourse.UserSelector = Discourse.TextField.extend({
selected = items;
},
transformComplete: transformTemplate,
reverseTransform: function(i) {
return { username: i };
}
@ -47,19 +52,39 @@ Discourse.UserSelector = Discourse.TextField.extend({
});
Handlebars.registerHelper("showMax", function(context, block){
var maxLength = parseInt(block.hash.max) || 3;
if (context.length > maxLength){
return context.slice(0, maxLength).join(", ") + ", +" + (context.length - maxLength);
} else {
return context.join(", ");
}
});
Discourse.UserSelector.reopenClass({
// I really want to move this into a template file, but I need a handlebars template here, not an ember one
templateFunction: function(){
this.compiled = this.compiled || Handlebars.compile("<div class='autocomplete'>" +
"<ul>" +
"{{#each options}}" +
"{{#each options.users}}" +
"<li>" +
"<a href='#'>{{avatar this imageSize=\"tiny\"}} " +
"<span class='username'>{{this.username}}</span> " +
"<span class='name'>{{this.name}}</span></a>" +
"</li>" +
"{{/each}}" +
"{{#if options.groups}}" +
"{{#if options.users}}<hr>{{/if}}"+
"{{#each options.groups}}" +
"<li>" +
"<a href=''><i class='icon-group'></i>" +
"<span class='username'>{{this.name}}</span> " +
"<span class='name'>{{showMax this.usernames max=3}}</span></a>" +
"</li>" +
"{{/each}}" +
"{{/if}}" +
"</ul>" +
"</div>");
return this.compiled;

View File

@ -16,16 +16,20 @@ class Admin::GroupsController < Admin::AdminController
def update
group = Group.find(params[:id].to_i)
if group.automatic
can_not_modify_automatic
# we can only change the alias level on automatic groups
group.alias_level = params[:group][:alias_level]
else
group.usernames = params[:group][:usernames]
group.alias_level = params[:group][:alias_level]
group.name = params[:group][:name] if params[:group][:name]
if group.save
render json: success_json
else
render_json_error group
end
end
if group.save
render json: success_json
else
render_json_error group
end
end

View File

@ -264,7 +264,13 @@ class UsersController < ApplicationController
user_fields = [:username, :use_uploaded_avatar, :upload_avatar_template, :uploaded_avatar_id]
user_fields << :name if SiteSetting.enable_names?
render json: { users: results.as_json(only: user_fields, methods: :avatar_template) }
to_render = { users: results.as_json(only: user_fields, methods: :avatar_template) }
if params[:include_groups] == "true"
to_render[:groups] = Group.search_group(term, current_user).map {|m| {:name=>m.name, :usernames=> m.usernames.split(",")} }
end
render json: to_render
end
# [LEGACY] avatars in quotes/oneboxes might still be pointing to this route

View File

@ -99,6 +99,23 @@ class Group < ActiveRecord::Base
lookup_group(name) || refresh_automatic_group!(name)
end
def self.search_group(name, current_user)
levels = [99]
if current_user.admin?
levels = [99, 1, 2, 3]
elsif current_user.moderator?
levels = [99, 2, 3]
end
return Group.where("name LIKE :term_like AND (" +
" alias_level in (:levels)" +
" OR (alias_level = 3 AND id in (" +
"SELECT group_id FROM group_users WHERE user_id= :user_id)" +
")" +
")", term_like: "#{name.downcase}%", levels: levels, user_id: current_user.id)
end
def self.lookup_group(name)
if id = AUTO_GROUPS[name]
Group.where(id: id).first

View File

@ -1,3 +1,3 @@
class BasicGroupSerializer < ApplicationSerializer
attributes :id, :automatic, :name, :user_count
attributes :id, :automatic, :name, :user_count, :alias_level
end

View File

@ -1219,10 +1219,16 @@ en:
selector_placeholder: "add users"
name_placeholder: "Group name, no spaces, same as username rule"
about: "Edit your group membership and names here"
can_not_edit_automatic: "Automatic group membership is determined automatically, administer users to assign roles and trust levels"
delete: "Delete"
delete_confirm: "Delete this group?"
delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed."
alias_levels:
title: "Who can use this group as an alias?"
nobody: "Nobody"
only_admins: "Only admins"
mods_and_admins: "Only moderators and Admins"
members_mods_and_admins: "Only group members, moderators and admins"
everyone: "Everyone"
api:
generate_master: "Generate Master API Key"

View File

@ -0,0 +1,5 @@
class AddAliasLevelToGroups < ActiveRecord::Migration
def change
add_column :groups, :alias_level, :integer, default: 0
end
end

View File

@ -21,7 +21,8 @@ describe Admin::GroupsController do
"id"=>group.id,
"name"=>group.name,
"user_count"=>1,
"automatic"=>false
"automatic"=>false,
"alias_level"=>0
}]
end