mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-12-17 19:12:37 -05:00
FEATURE: ability to restrict tags to categories using groups
This commit is contained in:
parent
f8051209ba
commit
a49ace0ffb
16 changed files with 222 additions and 12 deletions
|
@ -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'),
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
});
|
|
@ -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'
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
4
app/models/category_tag_group.rb
Normal file
4
app/models/category_tag_group.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class CategoryTagGroup < ActiveRecord::Base
|
||||||
|
belongs_to :category
|
||||||
|
belongs_to :tag_group
|
||||||
|
end
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
11
db/migrate/20160606204319_create_category_tag_groups.rb
Normal file
11
db/migrate/20160606204319_create_category_tag_groups.rb
Normal 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
|
|
@ -72,11 +72,34 @@ 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:
|
||||||
|
|
||||||
|
if opts[:category] && (opts[:category].tags.count > 0 || opts[:category].tag_groups.count > 0)
|
||||||
|
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)
|
query = query.where("tags.id IN (SELECT tag_id FROM category_tags WHERE category_id = ?)", opts[:category].id)
|
||||||
elsif CategoryTag.exists?
|
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)")
|
query = query.where("tags.id NOT IN (SELECT tag_id FROM category_tags)")
|
||||||
end
|
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
|
||||||
|
|
||||||
query
|
query
|
||||||
|
|
3
spec/fabricators/tag_group_fabricator.rb
Normal file
3
spec/fabricators/tag_group_fabricator.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
Fabricator(:tag_group) do
|
||||||
|
name { sequence(:name) { |i| "tag_group_#{i}" } }
|
||||||
|
end
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue