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:
parent
fd36fa1c2f
commit
c743a985a4
|
@ -35,6 +35,16 @@ Discourse.Group = Discourse.Model.extend({
|
||||||
return usernames;
|
return usernames;
|
||||||
}.property('users'),
|
}.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(){
|
destroy: function(){
|
||||||
if(!this.id) return;
|
if(!this.id) return;
|
||||||
|
|
||||||
|
@ -58,6 +68,7 @@ Discourse.Group = Discourse.Model.extend({
|
||||||
return Discourse.ajax("/admin/groups", {type: "POST", data: {
|
return Discourse.ajax("/admin/groups", {type: "POST", data: {
|
||||||
group: {
|
group: {
|
||||||
name: this.get('name'),
|
name: this.get('name'),
|
||||||
|
alias_level: this.get('alias_level'),
|
||||||
usernames: this.get('usernames')
|
usernames: this.get('usernames')
|
||||||
}
|
}
|
||||||
}}).then(function(resp) {
|
}}).then(function(resp) {
|
||||||
|
@ -83,6 +94,7 @@ Discourse.Group = Discourse.Model.extend({
|
||||||
data: {
|
data: {
|
||||||
group: {
|
group: {
|
||||||
name: this.get('name'),
|
name: this.get('name'),
|
||||||
|
alias_level: this.get('alias_level'),
|
||||||
usernames: this.get('usernames')
|
usernames: this.get('usernames')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -24,15 +24,23 @@
|
||||||
{{textField value=name placeholderKey="admin.groups.name_placeholder"}}
|
{{textField value=name placeholderKey="admin.groups.name_placeholder"}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
<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"}}
|
{{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'>
|
<div class='controls'>
|
||||||
{{#unless automatic}}
|
|
||||||
<button {{action save this}} {{bindAttr disabled="disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
|
<button {{action save this}} {{bindAttr disabled="disableSave"}} class='btn'>{{i18n admin.customize.save}}</button>
|
||||||
{{#if id}}
|
{{#unless automatic}}
|
||||||
<a {{action destroy this}} class='delete-link'>{{i18n admin.customize.delete}}</a>
|
<a {{action destroy this}} class='delete-link'>{{i18n admin.customize.delete}}</a>
|
||||||
{{/if}}
|
|
||||||
{{else}}
|
|
||||||
{{i18n admin.groups.can_not_edit_automatic}}
|
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
</div>
|
</div>
|
||||||
{{/with}}
|
{{/with}}
|
||||||
|
|
|
@ -92,19 +92,26 @@ $.fn.autocomplete = function(options) {
|
||||||
// dump what we have in single mode, just in case
|
// dump what we have in single mode, just in case
|
||||||
inputSelectedItems = [];
|
inputSelectedItems = [];
|
||||||
}
|
}
|
||||||
var d = $("<div class='item'><span>" + (transformed || item) + "<a class='remove' href='#'><i class='fa fa-times'></i></a></span></div>");
|
if (!_.isArray(transformed)) {
|
||||||
|
transformed = [transformed || 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');
|
var prev = me.parent().find('.item:last');
|
||||||
if (prev.length === 0) {
|
if (prev.length === 0) {
|
||||||
me.parent().prepend(d);
|
me.parent().prepend(d);
|
||||||
} else {
|
} else {
|
||||||
prev.after(d);
|
prev.after(d);
|
||||||
}
|
}
|
||||||
inputSelectedItems.push(item);
|
inputSelectedItems.push(itm);
|
||||||
|
return divs;
|
||||||
|
});
|
||||||
|
|
||||||
if (options.onChangeItems) {
|
if (options.onChangeItems) {
|
||||||
options.onChangeItems(inputSelectedItems);
|
options.onChangeItems(inputSelectedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
d.find('a').click(function() {
|
$(divs).find('a').click(function() {
|
||||||
closeAutocomplete();
|
closeAutocomplete();
|
||||||
inputSelectedItems.splice($.inArray(item, inputSelectedItems), 1);
|
inputSelectedItems.splice($.inArray(item, inputSelectedItems), 1);
|
||||||
$(this).parent().parent().remove();
|
$(this).parent().parent().remove();
|
||||||
|
|
|
@ -9,11 +9,12 @@ var cache = {};
|
||||||
var cacheTopicId = null;
|
var cacheTopicId = null;
|
||||||
var cacheTime = 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', {
|
return Discourse.ajax('/users/search/users', {
|
||||||
data: {
|
data: {
|
||||||
term: term,
|
term: term,
|
||||||
topic_id: topicId
|
topic_id: topicId,
|
||||||
|
include_groups: include_groups
|
||||||
}
|
}
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
cache[term] = r;
|
cache[term] = r;
|
||||||
|
@ -26,6 +27,7 @@ Discourse.UserSearch = {
|
||||||
|
|
||||||
search: function(options) {
|
search: function(options) {
|
||||||
var term = options.term || "";
|
var term = options.term || "";
|
||||||
|
var include_groups = options.include_groups || false;
|
||||||
var exclude = options.exclude || [];
|
var exclude = options.exclude || [];
|
||||||
var topicId = options.topicId;
|
var topicId = options.topicId;
|
||||||
var limit = options.limit || 5;
|
var limit = options.limit || 5;
|
||||||
|
@ -46,21 +48,35 @@ Discourse.UserSearch = {
|
||||||
cacheTopicId = topicId;
|
cacheTopicId = topicId;
|
||||||
|
|
||||||
var organizeResults = function(r) {
|
var organizeResults = function(r) {
|
||||||
var result = [];
|
var users = [], groups = [], results = [];
|
||||||
_.each(r.users,function(u) {
|
_.each(r.users,function(u) {
|
||||||
if (exclude.indexOf(u.username) === -1) {
|
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;
|
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]) {
|
if (cache[term]) {
|
||||||
organizeResults(cache[term]);
|
organizeResults(cache[term]);
|
||||||
} else {
|
} else {
|
||||||
debouncedSearch(term, topicId).then(organizeResults);
|
debouncedSearch(term, topicId, include_groups).then(organizeResults);
|
||||||
}
|
}
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
{{userSelector topicId=controller.controllers.topic.model.id
|
{{userSelector topicId=controller.controllers.topic.model.id
|
||||||
excludeCurrentUser="true"
|
excludeCurrentUser="true"
|
||||||
id="private-message-users"
|
id="private-message-users"
|
||||||
|
include_groups="true"
|
||||||
class="span8"
|
class="span8"
|
||||||
placeholderKey="composer.users_placeholder"
|
placeholderKey="composer.users_placeholder"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
{{i18n topic.invite_private.success}}
|
{{i18n topic.invite_private.success}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<label>{{i18n topic.invite_private.email_or_username}}</label>
|
<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}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|
|
@ -183,11 +183,18 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
|
||||||
dataSource: function(term) {
|
dataSource: function(term) {
|
||||||
return Discourse.UserSearch.search({
|
return Discourse.UserSearch.search({
|
||||||
term: term,
|
term: term,
|
||||||
topicId: composerView.get('controller.controllers.topic.model.id')
|
topicId: composerView.get('controller.controllers.topic.model.id'),
|
||||||
|
include_groups: true
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
key: "@",
|
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({
|
this.editor = editor = Discourse.Markdown.createEditor({
|
||||||
|
|
|
@ -4,7 +4,6 @@ Discourse.UserSelector = Discourse.TextField.extend({
|
||||||
|
|
||||||
var userSelectorView = this;
|
var userSelectorView = this;
|
||||||
var selected = [];
|
var selected = [];
|
||||||
var transformTemplate = Handlebars.compile("{{avatar this imageSize=\"tiny\"}} {{this.username}}");
|
|
||||||
|
|
||||||
$(this.get('element')).val(this.get('usernames')).autocomplete({
|
$(this.get('element')).val(this.get('usernames')).autocomplete({
|
||||||
template: Discourse.UserSelector.templateFunction(),
|
template: Discourse.UserSelector.templateFunction(),
|
||||||
|
@ -20,9 +19,17 @@ Discourse.UserSelector = Discourse.TextField.extend({
|
||||||
return Discourse.UserSearch.search({
|
return Discourse.UserSearch.search({
|
||||||
term: term,
|
term: term,
|
||||||
topicId: userSelectorView.get('topicId'),
|
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) {
|
onChangeItems: function(items) {
|
||||||
items = _.map(items, function(i) {
|
items = _.map(items, function(i) {
|
||||||
|
@ -36,8 +43,6 @@ Discourse.UserSelector = Discourse.TextField.extend({
|
||||||
selected = items;
|
selected = items;
|
||||||
},
|
},
|
||||||
|
|
||||||
transformComplete: transformTemplate,
|
|
||||||
|
|
||||||
reverseTransform: function(i) {
|
reverseTransform: function(i) {
|
||||||
return { username: 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({
|
Discourse.UserSelector.reopenClass({
|
||||||
// I really want to move this into a template file, but I need a handlebars template here, not an ember one
|
// I really want to move this into a template file, but I need a handlebars template here, not an ember one
|
||||||
templateFunction: function(){
|
templateFunction: function(){
|
||||||
this.compiled = this.compiled || Handlebars.compile("<div class='autocomplete'>" +
|
this.compiled = this.compiled || Handlebars.compile("<div class='autocomplete'>" +
|
||||||
"<ul>" +
|
"<ul>" +
|
||||||
"{{#each options}}" +
|
"{{#each options.users}}" +
|
||||||
"<li>" +
|
"<li>" +
|
||||||
"<a href='#'>{{avatar this imageSize=\"tiny\"}} " +
|
"<a href='#'>{{avatar this imageSize=\"tiny\"}} " +
|
||||||
"<span class='username'>{{this.username}}</span> " +
|
"<span class='username'>{{this.username}}</span> " +
|
||||||
"<span class='name'>{{this.name}}</span></a>" +
|
"<span class='name'>{{this.name}}</span></a>" +
|
||||||
"</li>" +
|
"</li>" +
|
||||||
"{{/each}}" +
|
"{{/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>" +
|
"</ul>" +
|
||||||
"</div>");
|
"</div>");
|
||||||
return this.compiled;
|
return this.compiled;
|
||||||
|
|
|
@ -16,18 +16,22 @@ class Admin::GroupsController < Admin::AdminController
|
||||||
|
|
||||||
def update
|
def update
|
||||||
group = Group.find(params[:id].to_i)
|
group = Group.find(params[:id].to_i)
|
||||||
|
|
||||||
if group.automatic
|
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
|
else
|
||||||
group.usernames = params[:group][:usernames]
|
group.usernames = params[:group][:usernames]
|
||||||
|
group.alias_level = params[:group][:alias_level]
|
||||||
group.name = params[:group][:name] if params[:group][:name]
|
group.name = params[:group][:name] if params[:group][:name]
|
||||||
|
end
|
||||||
|
|
||||||
if group.save
|
if group.save
|
||||||
render json: success_json
|
render json: success_json
|
||||||
else
|
else
|
||||||
render_json_error group
|
render_json_error group
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
group = Group.new
|
group = Group.new
|
||||||
|
|
|
@ -264,7 +264,13 @@ class UsersController < ApplicationController
|
||||||
user_fields = [:username, :use_uploaded_avatar, :upload_avatar_template, :uploaded_avatar_id]
|
user_fields = [:username, :use_uploaded_avatar, :upload_avatar_template, :uploaded_avatar_id]
|
||||||
user_fields << :name if SiteSetting.enable_names?
|
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
|
end
|
||||||
|
|
||||||
# [LEGACY] avatars in quotes/oneboxes might still be pointing to this route
|
# [LEGACY] avatars in quotes/oneboxes might still be pointing to this route
|
||||||
|
|
|
@ -99,6 +99,23 @@ class Group < ActiveRecord::Base
|
||||||
lookup_group(name) || refresh_automatic_group!(name)
|
lookup_group(name) || refresh_automatic_group!(name)
|
||||||
end
|
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)
|
def self.lookup_group(name)
|
||||||
if id = AUTO_GROUPS[name]
|
if id = AUTO_GROUPS[name]
|
||||||
Group.where(id: id).first
|
Group.where(id: id).first
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
class BasicGroupSerializer < ApplicationSerializer
|
class BasicGroupSerializer < ApplicationSerializer
|
||||||
attributes :id, :automatic, :name, :user_count
|
attributes :id, :automatic, :name, :user_count, :alias_level
|
||||||
end
|
end
|
||||||
|
|
|
@ -1219,10 +1219,16 @@ en:
|
||||||
selector_placeholder: "add users"
|
selector_placeholder: "add users"
|
||||||
name_placeholder: "Group name, no spaces, same as username rule"
|
name_placeholder: "Group name, no spaces, same as username rule"
|
||||||
about: "Edit your group membership and names here"
|
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: "Delete"
|
||||||
delete_confirm: "Delete this group?"
|
delete_confirm: "Delete this group?"
|
||||||
delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed."
|
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:
|
api:
|
||||||
generate_master: "Generate Master API Key"
|
generate_master: "Generate Master API Key"
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddAliasLevelToGroups < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
add_column :groups, :alias_level, :integer, default: 0
|
||||||
|
end
|
||||||
|
end
|
|
@ -21,7 +21,8 @@ describe Admin::GroupsController do
|
||||||
"id"=>group.id,
|
"id"=>group.id,
|
||||||
"name"=>group.name,
|
"name"=>group.name,
|
||||||
"user_count"=>1,
|
"user_count"=>1,
|
||||||
"automatic"=>false
|
"automatic"=>false,
|
||||||
|
"alias_level"=>0
|
||||||
}]
|
}]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue