FEATURE: Added UI for adding and removing watched and muted categories

This commit is contained in:
Sam 2014-01-02 17:58:49 +11:00
parent 1b259c59a5
commit 2da5d2311b
14 changed files with 186 additions and 12 deletions

View File

@ -0,0 +1,42 @@
Discourse.CategoryGroupComponent = Ember.Component.extend({
didInsertElement: function(){
var self = this;
this.$('input').autocomplete({
items: this.get('categories'),
single: false,
allowAny: false,
dataSource: function(term){
return Discourse.Category.list().filter(function(category){
var regex = new RegExp(term, "i");
return category.get("name").match(regex) &&
!_.contains(self.get('categories'), category);
});
},
onChangeItems: function(items) {
self.set("categories", items);
},
template: Discourse.CategoryGroupComponent.templateFunction(),
transformComplete: function(category){
return Discourse.HTML.categoryLink(category);
}
});
}
});
Discourse.CategoryGroupComponent.reopenClass({
templateFunction: function(){
this.compiled = this.compiled || Handlebars.compile("<div class='autocomplete'>" +
"<ul>" +
"{{#each options}}" +
"<li>" +
"{{categoryLinkRaw this}}" +
"</li>" +
"{{/each}}" +
"</ul>" +
"</div>");
return this.compiled;
}
});

View File

@ -65,6 +65,10 @@ Handlebars.registerHelper('categoryLink', function(property, options) {
return categoryLinkHTML(Ember.Handlebars.get(this, property, options), options);
});
Handlebars.registerHelper('categoryLinkRaw', function(property, options) {
return categoryLinkHTML(property, options);
});
/**
Produces a bound link to a category

View File

@ -74,7 +74,6 @@ $.fn.autocomplete = function(options) {
var isInput = this[0].tagName === "INPUT";
var inputSelectedItems = [];
var closeAutocomplete = function() {
if (div) {
div.hide().remove();
@ -93,7 +92,7 @@ $.fn.autocomplete = function(options) {
// dump what we have in single mode, just in case
inputSelectedItems = [];
}
var d = $("<div class='item'><span>" + (transformed || item) + "<a href='#'><i class='fa fa-times'></i></a></span></div>");
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);
@ -158,6 +157,11 @@ $.fn.autocomplete = function(options) {
addInputSelectedItem(x);
}
});
if(options.items) {
_.each(options.items, function(item){
addInputSelectedItem(item);
});
}
this.val("");
completeStart = 0;
wrap.click(function() {
@ -225,8 +229,14 @@ $.fn.autocomplete = function(options) {
};
var updateAutoComplete = function(r) {
if (completeStart === null) return;
if (r && r.then && typeof(r.then) === "function") {
r.then(updateAutoComplete);
return;
}
autocompleteOptions = r;
if (!r || r.length === 0) {
closeAutocomplete();
@ -257,7 +267,7 @@ $.fn.autocomplete = function(options) {
if (!prevChar || /\s/.test(prevChar)) {
completeStart = completeEnd = caretPosition;
var term = "";
options.dataSource(term).then(updateAutoComplete);
updateAutoComplete(options.dataSource(term));
}
}
});
@ -304,7 +314,7 @@ $.fn.autocomplete = function(options) {
completeStart = c;
caretPosition = completeEnd = initial;
term = me[0].value.substring(c + 1, initial);
options.dataSource(term).then(updateAutoComplete);
updateAutoComplete(options.dataSource(term));
return true;
}
}
@ -395,7 +405,7 @@ $.fn.autocomplete = function(options) {
}
}
options.dataSource(term).then(updateAutoComplete);
updateAutoComplete(options.dataSource(term));
return true;
}
}

View File

@ -204,6 +204,11 @@ Discourse.Category.reopenClass({
});
},
// TODO: optimise, slow for no real reason
findById: function(id){
return Discourse.Category.list().findBy('id', id);
},
findBySlug: function(slug, parentSlug) {
var categories = Discourse.Category.list(),

View File

@ -167,8 +167,7 @@ Discourse.User = Discourse.Model.extend({
**/
save: function() {
var user = this;
return Discourse.ajax("/users/" + this.get('username_lower'), {
data: this.getProperties('auto_track_topics_after_msecs',
var data = this.getProperties('auto_track_topics_after_msecs',
'bio_raw',
'website',
'name',
@ -181,7 +180,12 @@ Discourse.User = Discourse.Model.extend({
'new_topic_duration_minutes',
'external_links_in_new_tab',
'watch_new_topics',
'enable_quoting'),
'enable_quoting');
data.watched_category_ids = this.get('watchedCategories').map(function(c){ return c.get('id')});
data.muted_category_ids = this.get('mutedCategories').map(function(c){ return c.get('id')});
return Discourse.ajax("/users/" + this.get('username_lower'), {
data: data,
type: 'PUT'
}).then(function(data) {
user.set('bio_excerpt',data.user.bio_excerpt);
@ -350,8 +354,19 @@ Discourse.User = Discourse.Model.extend({
}
}
return Discourse.Utilities.defaultHomepage();
}.property("trust_level", "hasBeenSeenInTheLastMonth")
}.property("trust_level", "hasBeenSeenInTheLastMonth"),
updateMutedCategories: function() {
this.set("mutedCategories", _.map(this.muted_category_ids, function(id){
return Discourse.Category.findById(id);
}));
}.observes("muted_category_ids"),
updateWatchedCategories: function() {
this.set("watchedCategories", _.map(this.watched_category_ids, function(id){
return Discourse.Category.findById(id);
}));
}.observes("watched_category_ids")
});
Discourse.User.reopenClass(Discourse.Singleton, {

View File

@ -0,0 +1 @@
<input class='category-group' type='text'>

View File

@ -134,6 +134,20 @@
</div>
<div class="control-group category">
<label class="control-label">{{i18n user.categories_settings}}</label>
<div class="controls">
<label>{{i18n user.watched_categories}}</label>
{{category-group categories=watchedCategories}}
<div class="instructions">{{i18n user.watched_categories_instructions}}</div>
</div>
<div class="controls">
<label>{{i18n user.muted_categories}}</label>
{{category-group categories=mutedCategories}}
<div class="instructions">{{i18n user.muted_categories_instructions}}</div>
</div>
</div>
<div class="control-group">
<div class="controls">
<button {{action save}} {{bindAttr disabled="saveDisabled"}} class="btn btn-primary">{{saveButtonText}}</button>

View File

@ -387,7 +387,7 @@ div.ac-wrap {
line-height: 22px;
vertical-align: bottom;
}
a {
a.remove {
margin-left: 4px;
font-size: 10px;
line-height: 10px;

View File

@ -3,6 +3,19 @@
@import "common/foundation/mixins";
.user-preferences {
input.category-group {
width: 500px;
}
.autocomplete .badge-category {
margin: 2px;
font-weight: normal;
}
.autocomplete .badge-category.selected {
font-weight: bold;
}
textarea {
width: 530px;
height: 100px;

View File

@ -2,6 +2,10 @@ class CategoryUser < ActiveRecord::Base
belongs_to :category
belongs_to :user
def self.lookup(user, level)
self.where(user: user, notification_level: notification_levels[level])
end
# same for now
def self.notification_levels
TopicUser.notification_levels
@ -15,6 +19,21 @@ class CategoryUser < ActiveRecord::Base
)
end
def self.batch_set(user, level, category_ids)
records = CategoryUser.where(user: user, notification_level: notification_levels[level])
old_ids = records.pluck(:category_id)
remove = (old_ids - category_ids)
if remove.present?
records.where('category_id in (?)', remove).destroy_all
end
(category_ids - old_ids).each do |id|
CategoryUser.create!(user: user, category_id: id, notification_level: notification_levels[level])
end
end
def self.auto_mute_new_topic(topic)
apply_default_to_topic(
topic,
@ -23,6 +42,15 @@ class CategoryUser < ActiveRecord::Base
)
end
def notification_level1=(val)
val = Symbol === val ? CategoryUser.notification_levels[val] : val
attributes[:notification_level] = val
end
def notification_level1
attributes[:notification_level]
end
private
def self.apply_default_to_topic(topic, level, reason)

View File

@ -60,7 +60,9 @@ class UserSerializer < BasicUserSerializer
:use_uploaded_avatar,
:has_uploaded_avatar,
:gravatar_template,
:uploaded_avatar_template
:uploaded_avatar_template,
:muted_category_ids,
:watched_category_ids
def auto_track_topics_after_msecs
@ -101,8 +103,16 @@ class UserSerializer < BasicUserSerializer
def include_suspend_reason?
object.suspended?
end
def include_suspended_till?
object.suspended?
end
def muted_category_ids
CategoryUser.lookup(object, :muted).pluck(:category_id)
end
def watched_category_ids
CategoryUser.lookup(object, :watching).pluck(:category_id)
end
end

View File

@ -11,8 +11,16 @@ class UserUpdater
user.name = attributes[:name] || user.name
user.digest_after_days = attributes[:digest_after_days] || user.digest_after_days
if ids = attributes[:watched_category_ids]
CategoryUser.batch_set(user, :watching, ids)
end
if ids = attributes[:muted_category_ids]
CategoryUser.batch_set(user, :muted, ids)
end
if attributes[:auto_track_topics_after_msecs]
user.auto_track_topics_after_msecs = attributes[:auto_track_topics_after_msecs].to_i
user.auto_track_topics_after_msecs = attributes[:auto_track_topics_after_msecs].to_i
end
if attributes[:new_topic_duration_minutes]

View File

@ -228,6 +228,10 @@ en:
suspended_notice: "This user is suspended until {{date}}."
suspended_reason: "Reason: "
watch_new_topics: "Automatically watch all new topics posted on the forum"
watched_categories: "Watched"
watched_categories_instructions: "You will automatically watch all topics in these categories"
muted_categories: "Muted"
muted_categories_instructions: "You will automatically mute all topics in these categories"
messages:
all: "All"
@ -313,6 +317,7 @@ en:
email_always: "Receive email notifications and email digests even if I am active on the forum"
other_settings: "Other"
categories_settings: "Categories"
new_topic_duration:
label: "Consider topics new when"

View File

@ -4,6 +4,25 @@ require 'spec_helper'
require_dependency 'post_creator'
describe CategoryUser do
it 'allows batch set' do
user = Fabricate(:user)
category1 = Fabricate(:category)
category2 = Fabricate(:category)
watching = CategoryUser.where(user_id: user.id, notification_level: CategoryUser.notification_levels[:watching])
CategoryUser.batch_set(user, :watching, [category1.id, category2.id])
watching.pluck(:category_id).sort.should == [category1.id, category2.id]
CategoryUser.batch_set(user, :watching, [])
watching.count.should == 0
CategoryUser.batch_set(user, :watching, [category2.id])
watching.count.should == 1
end
context 'integration' do
before do
ActiveRecord::Base.observers.enable :all