FEATURE: ability to restrict tags to categories using groups

This commit is contained in:
Neil Lalonde 2016-06-07 13:08:59 -04:00
parent f8051209ba
commit a49ace0ffb
16 changed files with 222 additions and 12 deletions

View file

@ -8,7 +8,7 @@ export default Ember.TextField.extend({
classNameBindings: [':tag-chooser'], classNameBindings: [':tag-chooser'],
attributeBindings: ['tabIndex', 'placeholderKey', 'categoryId'], attributeBindings: ['tabIndex', 'placeholderKey', 'categoryId'],
_setupTags: function() { _initValue: function() {
const tags = this.get('tags') || []; const tags = this.get('tags') || [];
this.set('value', tags.join(", ")); this.set('value', tags.join(", "));
}.on('init'), }.on('init'),
@ -79,7 +79,7 @@ export default Ember.TextField.extend({
list.push(item); list.push(item);
}, },
formatSelection: function (data) { formatSelection: function (data) {
return data ? renderTag(this.text(data)) : undefined; return data ? renderTag(this.text(data)) : undefined;
}, },
formatSelectionCssClass: function(){ formatSelectionCssClass: function(){
return "discourse-tag-select2"; return "discourse-tag-select2";

View file

@ -0,0 +1,84 @@
function renderTagGroup(tag) {
return "<a class='discourse-tag'>" + Handlebars.Utils.escapeExpression(tag.text ? tag.text : tag) + "</a>";
};
export default Ember.TextField.extend({
classNameBindings: [':tag-chooser'],
attributeBindings: ['tabIndex', 'placeholderKey', 'categoryId'],
_initValue: function() {
const names = this.get('tagGroups') || [];
this.set('value', names.join(", "));
}.on('init'),
_valueChanged: function() {
const names = this.get('value').split(',').map(v => v.trim()).reject(v => v.length === 0).uniq();
this.set('tagGroups', names);
}.observes('value'),
_tagGroupsChanged: function() {
const $chooser = this.$(),
val = this.get('value');
if ($chooser && val !== this.get('tagGroups')) {
if (this.get('tagGroups')) {
const data = this.get('tagGroups').map((t) => {return {id: t, text: t};});
$chooser.select2('data', data);
} else {
$chooser.select2('data', []);
}
}
}.observes('tagGroups'),
_initializeChooser: function() {
const self = this;
this.$().select2({
tags: true,
placeholder: this.get('placeholderKey') ? I18n.t(this.get('placeholderKey')) : null,
initSelection(element, callback) {
const data = [];
function splitVal(string, separator) {
var val, i, l;
if (string === null || string.length < 1) return [];
val = string.split(separator);
for (i = 0, l = val.length; i < l; i = i + 1) val[i] = $.trim(val[i]);
return val;
}
$(splitVal(element.val(), ",")).each(function () {
data.push({ id: this, text: this });
});
callback(data);
},
formatSelection: function (data) {
return data ? renderTagGroup(this.text(data)) : undefined;
},
formatSelectionCssClass: function(){
return "discourse-tag-select2";
},
formatResult: renderTagGroup,
multiple: true,
ajax: {
quietMillis: 200,
cache: true,
url: Discourse.getURL("/tag_groups/filter/search"),
dataType: 'json',
data: function (term) {
return { q: term, limit: self.siteSettings.max_tag_search_results };
},
results: function (data) {
data.results = data.results.sort(function(a,b) { return a.text > b.text; });
return data;
}
},
});
}.on('didInsertElement'),
_destroyChooser: function() {
this.$().select2('destroy');
}.on('willDestroyElement')
});

View file

@ -87,7 +87,8 @@ const Category = RestModel.extend({
custom_fields: this.get('custom_fields'), custom_fields: this.get('custom_fields'),
topic_template: this.get('topic_template'), topic_template: this.get('topic_template'),
suppress_from_homepage: this.get('suppress_from_homepage'), suppress_from_homepage: this.get('suppress_from_homepage'),
allowed_tags: this.get('allowed_tags') allowed_tags: this.get('allowed_tags'),
allowed_tag_groups: this.get('allowed_tag_groups')
}, },
type: this.get('id') ? 'PUT' : 'POST' type: this.get('id') ? 'PUT' : 'POST'
}); });

View file

@ -1,4 +1,7 @@
<section class="field"> <section class="field">
<p>{{i18n 'category.tags_allowed_tags'}}</p> <p>{{i18n 'category.tags_allowed_tags'}}</p>
{{tag-chooser placeholderKey="category.tags_placeholder" tags=category.allowed_tags}} {{tag-chooser placeholderKey="category.tags_placeholder" tags=category.allowed_tags}}
<p>{{i18n 'category.tags_allowed_tag_groups'}}</p>
{{tag-group-chooser placeholderKey="category.tag_groups_placeholder" tagGroups=category.allowed_tag_groups}}
</section> </section>

View file

@ -180,7 +180,10 @@ class CategoriesController < ApplicationController
end end
end end
params[:allowed_tags] ||= [] if SiteSetting.tagging_enabled if SiteSetting.tagging_enabled
params[:allowed_tags] ||= []
params[:allowed_tag_groups] ||= []
end
params.permit(*required_param_keys, params.permit(*required_param_keys,
:position, :position,
@ -197,7 +200,8 @@ class CategoriesController < ApplicationController
:topic_template, :topic_template,
:custom_fields => [params[:custom_fields].try(:keys)], :custom_fields => [params[:custom_fields].try(:keys)],
:permissions => [*p.try(:keys)], :permissions => [*p.try(:keys)],
:allowed_tags => []) :allowed_tags => [],
:allowed_tag_groups => [])
end end
end end

View file

@ -49,6 +49,19 @@ class TagGroupsController < ApplicationController
render json: success_json render json: success_json
end end
def search
matches = if params[:q].present?
term = params[:q].strip.downcase
TagGroup.where('lower(name) like ?', "%#{term}%")
else
TagGroup.all
end
matches = matches.order('name').limit(params[:limit] || 5)
render json: { results: matches.map { |x| { id: x.name, text: x.name } } }
end
private private
def fetch_tag_group def fetch_tag_group

View file

@ -55,8 +55,10 @@ class Category < ActiveRecord::Base
belongs_to :parent_category, class_name: 'Category' belongs_to :parent_category, class_name: 'Category'
has_many :subcategories, class_name: 'Category', foreign_key: 'parent_category_id' has_many :subcategories, class_name: 'Category', foreign_key: 'parent_category_id'
has_many :category_tags has_many :category_tags, dependent: :destroy
has_many :tags, through: :category_tags has_many :tags, through: :category_tags
has_many :category_tag_groups, dependent: :destroy
has_many :tag_groups, through: :category_tag_groups
scope :latest, ->{ order('topic_count desc') } scope :latest, ->{ order('topic_count desc') }
@ -319,6 +321,10 @@ SQL
DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg) DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg)
end end
def allowed_tag_groups=(group_names)
self.tag_groups = TagGroup.where(name: group_names).all.to_a
end
def downcase_email def downcase_email
self.email_in = (email_in || "").strip.downcase.presence self.email_in = (email_in || "").strip.downcase.presence
end end

View file

@ -0,0 +1,4 @@
class CategoryTagGroup < ActiveRecord::Base
belongs_to :category
belongs_to :tag_group
end

View file

@ -1,6 +1,10 @@
class TagGroup < ActiveRecord::Base class TagGroup < ActiveRecord::Base
validates_uniqueness_of :name, case_sensitive: false
has_many :tag_group_memberships, dependent: :destroy has_many :tag_group_memberships, dependent: :destroy
has_many :tags, through: :tag_group_memberships has_many :tags, through: :tag_group_memberships
has_many :category_tag_groups, dependent: :destroy
has_many :categories, through: :category_tag_groups
def tag_names=(tag_names_arg) def tag_names=(tag_names_arg)
DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg) DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg)

View file

@ -14,7 +14,8 @@ class CategorySerializer < BasicCategorySerializer
:is_special, :is_special,
:allow_badges, :allow_badges,
:custom_fields, :custom_fields,
:allowed_tags :allowed_tags,
:allowed_tag_groups
def group_permissions def group_permissions
@group_permissions ||= begin @group_permissions ||= begin
@ -86,4 +87,12 @@ class CategorySerializer < BasicCategorySerializer
object.tags.pluck(:name) object.tags.pluck(:name)
end end
def include_allowed_tag_groups?
SiteSetting.tagging_enabled
end
def allowed_tag_groups
object.tag_groups.pluck(:name)
end
end end

View file

@ -1732,7 +1732,9 @@ en:
topic_template: "Topic Template" topic_template: "Topic Template"
tags: "Tags" tags: "Tags"
tags_allowed_tags: "Tags that can only be used in this category:" tags_allowed_tags: "Tags that can only be used in this category:"
tags_allowed_tag_groups: "Tag groups that can only be used in this category:"
tags_placeholder: "(Optional) list of allowed tags" tags_placeholder: "(Optional) list of allowed tags"
tag_groups_placeholder: "(Optional) list of allowed tag groups"
delete: 'Delete Category' delete: 'Delete Category'
create: 'New Category' create: 'New Category'
create_long: 'Create a new category' create_long: 'Create a new category'

View file

@ -636,7 +636,12 @@ Discourse::Application.routes.draw do
end end
end end
end end
resources :tag_groups, except: [:new, :edit]
resources :tag_groups, except: [:new, :edit] do
collection do
get '/filter/search' => 'tag_groups#search'
end
end
Discourse.filters.each do |filter| Discourse.filters.each do |filter|
root to: "list##{filter}", constraints: HomePageConstraint.new("#{filter}"), :as => "list_#{filter}" root to: "list##{filter}", constraints: HomePageConstraint.new("#{filter}"), :as => "list_#{filter}"

View file

@ -0,0 +1,11 @@
class CreateCategoryTagGroups < ActiveRecord::Migration
def change
create_table :category_tag_groups do |t|
t.references :category, null: false
t.references :tag_group, null: false
t.timestamps
end
add_index :category_tag_groups, [:category_id, :tag_group_id], name: "idx_category_tag_groups_ix1", unique: true
end
end

View file

@ -72,10 +72,33 @@ module DiscourseTagging
query = query.where('tags.name NOT IN (?)', staff_tag_names) if staff_tag_names.present? query = query.where('tags.name NOT IN (?)', staff_tag_names) if staff_tag_names.present?
end end
if opts[:category] && opts[:category].tags.count > 0 # Filters for category-specific tags:
query = query.where("tags.id IN (SELECT tag_id FROM category_tags WHERE category_id = ?)", opts[:category].id)
elsif CategoryTag.exists? if opts[:category] && (opts[:category].tags.count > 0 || opts[:category].tag_groups.count > 0)
query = query.where("tags.id NOT IN (SELECT tag_id FROM category_tags)") if opts[:category].tags.count > 0 && opts[:category].tag_groups.count > 0
tag_group_ids = opts[:category].tag_groups.pluck(:id)
query = query.where(
"tags.id IN (SELECT tag_id FROM category_tags WHERE category_id = ?
UNION
SELECT tag_id FROM tag_group_memberships WHERE tag_group_id = ?)",
opts[:category].id, tag_group_ids
)
elsif opts[:category].tags.count > 0
query = query.where("tags.id IN (SELECT tag_id FROM category_tags WHERE category_id = ?)", opts[:category].id)
else # opts[:category].tag_groups.count > 0
tag_group_ids = opts[:category].tag_groups.pluck(:id)
query = query.where("tags.id IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id = ?)", tag_group_ids)
end
else
# exclude tags that are restricted to other categories
if CategoryTag.exists?
query = query.where("tags.id NOT IN (SELECT tag_id FROM category_tags)")
end
if CategoryTagGroup.exists?
tag_group_ids = CategoryTagGroup.pluck(:tag_group_id).uniq
query = query.where("tags.id NOT IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id = ?)", tag_group_ids)
end
end end
end end

View file

@ -0,0 +1,3 @@
Fabricator(:tag_group) do
name { sequence(:name) { |i| "tag_group_#{i}" } }
end

View file

@ -4,6 +4,11 @@ require 'rails_helper'
require_dependency 'post_creator' require_dependency 'post_creator'
describe "category tag restrictions" do describe "category tag restrictions" do
def sorted_tag_names(tag_records)
tag_records.map(&:name).sort
end
let!(:tag1) { Fabricate(:tag) } let!(:tag1) { Fabricate(:tag) }
let!(:tag2) { Fabricate(:tag) } let!(:tag2) { Fabricate(:tag) }
let!(:tag3) { Fabricate(:tag) } let!(:tag3) { Fabricate(:tag) }
@ -57,4 +62,37 @@ describe "category tag restrictions" do
expect { other_category.update(allowed_tags: [tag1.name, 'tag-stuff', tag2.name, 'another-tag']) }.to change { Tag.count }.by(2) expect { other_category.update(allowed_tags: [tag1.name, 'tag-stuff', tag2.name, 'another-tag']) }.to change { Tag.count }.by(2)
end end
end end
context "tag groups restricted to a category" do
let!(:tag_group1) { Fabricate(:tag_group) }
let(:category) { Fabricate(:category) }
let(:other_category) { Fabricate(:category) }
before do
tag_group1.tags = [tag1, tag2]
end
it "tags in the group are used by category tag restrictions" do
category.allowed_tag_groups = [tag_group1.name]
category.reload
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true, category: category}))).to eq(sorted_tag_names([tag1, tag2]))
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true}))).to eq(sorted_tag_names([tag3, tag4]))
tag_group1.tags = [tag2, tag3, tag4]
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true, category: category}))).to eq(sorted_tag_names([tag2, tag3, tag4]))
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true}))).to eq(sorted_tag_names([tag1]))
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true, category: other_category}))).to eq(sorted_tag_names([tag1]))
end
it "groups and individual tags can be mixed" do
category.allowed_tag_groups = [tag_group1.name]
category.allowed_tags = [tag4.name]
category.reload
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true, category: category}))).to eq(sorted_tag_names([tag1, tag2, tag4]))
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true}))).to eq(sorted_tag_names([tag3]))
expect(sorted_tag_names(DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), {for_input: true, category: other_category}))).to eq(sorted_tag_names([tag3]))
end
end
end end