Added method for testing ember stuff

Collapse user actions in UI so it stops looking crazy
Removed dud dupe user action TOPIC_RESPONSE
Always show the owner of a post on the user page, actions by others at the bottom
This commit is contained in:
Sam Saffron 2013-02-13 20:38:43 +11:00
parent 4e9d9138d6
commit 161420fac0
19 changed files with 304 additions and 2129 deletions

View File

@ -220,7 +220,6 @@ window.Discourse = Ember.Application.createWithMixins
Discourse.KeyValueStore.init("discourse_", Discourse.MessageBus)
Discourse.insertProbes()
# subscribe to any site customizations that are loaded
$('link.custom-css').each ->
split = @href.split("/")

View File

@ -1,10 +1,3 @@
RESPONSE = "6"
MENTION = "7"
TOPIC_RESPONSE = "8"
QUOTE = "9"
NEW_PRIVATE_MESSAGE = "12"
GOT_PRIVATE_MESSAGE = "13"
window.Discourse.User = Discourse.Model.extend Discourse.Presence,
avatarLarge: (->
@ -68,7 +61,7 @@ window.Discourse.User = Discourse.Model.extend Discourse.Presence,
callback(message)
filterStream: (filter)->
filter = Discourse.User.statGroups[filter].join(",") if Discourse.User.statGroups[filter]
filter = Discourse.UserAction.statGroups[filter].join(",") if Discourse.UserAction.statGroups[filter]
@set('streamFilter', filter)
@set('stream', Em.A())
@loadMoreUserActions()
@ -97,6 +90,7 @@ window.Discourse.User = Discourse.Model.extend Discourse.Presence,
if result and result.user_actions and result.user_actions.each
result.user_actions.each (i)=>
stream.pushObject(Discourse.UserAction.create(i))
stream = Discourse.UserAction.collapseStream(stream)
@set('stream', stream)
callback() if callback
@ -104,7 +98,7 @@ window.Discourse.User = Discourse.Model.extend Discourse.Presence,
total=0
return 0 unless stats = @get('stats')
@get('stats').each (s)->
total+= parseInt(s.count) unless s.action_type is NEW_PRIVATE_MESSAGE || s.action_type is GOT_PRIVATE_MESSAGE
total+= parseInt(s.count) unless s.get("isPM")
total
).property('stats.@each')
@ -112,7 +106,7 @@ window.Discourse.User = Discourse.Model.extend Discourse.Presence,
r = []
return r if @blank('stats')
@get('stats').each (s)->
r.push s unless (s.action_type == NEW_PRIVATE_MESSAGE || s.action_type == GOT_PRIVATE_MESSAGE)
r.push s unless s.get('isPM')
r
).property('stats.@each')
@ -120,14 +114,14 @@ window.Discourse.User = Discourse.Model.extend Discourse.Presence,
r = []
return r if @blank('stats')
@get('stats').each (s)->
r.push s if (s.action_type is NEW_PRIVATE_MESSAGE or s.action_type is GOT_PRIVATE_MESSAGE)
r.push s if s.get('isPM')
r
).property('stats.@each')
inboxCount: (->
r = 0
@get('stats').each (s)->
if s.action_type is GOT_PRIVATE_MESSAGE
if s.action_type == Discourse.UserAction.GOT_PRIVATE_MESSAGE
r = s.count
return false
return r
@ -136,7 +130,7 @@ window.Discourse.User = Discourse.Model.extend Discourse.Presence,
sentItemsCount: (->
r = 0
@get('stats').each (s)->
if s.action_type is NEW_PRIVATE_MESSAGE
if s.action_type == Discourse.UserAction.NEW_PRIVATE_MESSAGE
r = s.count
return false
return r
@ -155,10 +149,13 @@ window.Discourse.User.reopenClass
g = {}
stats.each (s) =>
found = false
for k,v of @statGroups
for k,v of Discourse.UserAction.statGroups
if v.contains(s.action_type)
found = true
g[k] = {count: 0} unless g[k]
g[k] = Em.Object.create(
description: Em.String.i18n("user_action_descriptions.#{k}")
count: 0
action_type: parseInt(k,10)) unless g[k]
g[k].count += parseInt(s.count)
c = g[k].count
if s.action_type == k
@ -172,24 +169,26 @@ window.Discourse.User.reopenClass
).exclude (s)->
!s
statGroups: (->
g = {}
g[RESPONSE] = [RESPONSE,MENTION,TOPIC_RESPONSE,QUOTE]
g
)()
find: (username) ->
promise = new RSVP.Promise()
$.ajax
url: "/users/" + username + '.json',
success: (json) =>
json.user.stats = @groupStats(json.user.stats)
json.user.stream = json.user.stream.map (ua) -> Discourse.UserAction.create(ua) if json.user.stream
# todo: decompose to object
json.user.stats = @groupStats(json.user.stats.map (s)->
obj = Em.Object.create(s)
obj.isPM = obj.action_type == Discourse.UserAction.NEW_PRIVATE_MESSAGE ||
obj.action_type == Discourse.UserAction.GOT_PRIVATE_MESSAGE
obj
)
json.user.stream = Discourse.UserAction.collapseStream(json.user.stream.map (ua) ->
Discourse.UserAction.create(ua)) if json.user.stream
user = Discourse.User.create(json.user)
promise.resolve(user)
error: (xhr) -> promise.reject(xhr)
promise
createAccount: (name, email, password, username, passwordConfirm, challenge) ->
$.ajax
url: '/users'

View File

@ -2,3 +2,104 @@ window.Discourse.UserAction = Discourse.Model.extend
postUrl:(->
Discourse.Utilities.postUrl(@get('slug'), @get('topic_id'), @get('post_number'))
).property()
isPM: (->
a = @get('action_type')
a == UserAction.NEW_PRIVATE_MESSAGE || UserAction.GOT_PRIVATE_MESSAGE
).property()
addChild: (action)->
groups = @get("childGroups")
unless groups
groups =
likes: Discourse.UserActionGroup.create(icon: "icon-heart")
stars: Discourse.UserActionGroup.create(icon: "icon-star")
edits: Discourse.UserActionGroup.create(icon: "icon-pencil")
bookmarks: Discourse.UserActionGroup.create(icon: "icon-bookmark")
@set("childGroups", groups)
ua = Discourse.UserAction
bucket = switch action.action_type
when ua.LIKE,ua.WAS_LIKED then "likes"
when ua.STAR then "stars"
when ua.EDIT then "edits"
when ua.BOOKMARK then "bookmarks"
current = groups[bucket]
current.push(action) if current
return
children:(->
g = @get("childGroups")
rval = []
if g
rval = [g.likes, g.stars, g.edits, g.bookmarks].filter((i) -> i.get("items") && i.get("items").length > 0)
rval
).property("childGroups")
switchToActing: ->
@set('username', @get('acting_username'))
@set('avatar_template', @get('acting_avatar_template'))
@set('name', @get('acting_name'))
window.Discourse.UserAction.reopenClass
collapseStream: (stream) ->
collapse = [@LIKE, @WAS_LIKED, @STAR, @EDIT, @BOOKMARK]
uniq = {}
collapsed = Em.A()
pos = 0
stream.each (item)->
key = "#{item.topic_id}-#{item.post_number}"
found = uniq[key]
if found == undefined
if collapse.indexOf(item.action_type) >= 0
current = Discourse.UserAction.create(item)
current.set('action_type',null)
current.set('description',null)
item.switchToActing()
current.addChild(item)
else
current = item
uniq[key] = pos
collapsed[pos] = current
pos += 1
else
if collapse.indexOf(item.action_type) >= 0
item.switchToActing()
collapsed[found].addChild(item)
else
collapsed[found].set('action_type', item.get('action_type'))
collapsed[found].set('description', item.get('description'))
collapsed
# in future we should be sending this through from the server
LIKE: 1
WAS_LIKED: 2
BOOKMARK: 3
NEW_TOPIC: 4
POST: 5
RESPONSE: 6
MENTION: 7
QUOTE: 9
STAR: 10
EDIT: 11
NEW_PRIVATE_MESSAGE: 12
GOT_PRIVATE_MESSAGE: 13
window.Discourse.UserAction.reopenClass
statGroups: (->
g = {}
g[Discourse.UserAction.RESPONSE] = [
Discourse.UserAction.RESPONSE,
Discourse.UserAction.MENTION,
Discourse.UserAction.QUOTE
]
g
)()

View File

@ -0,0 +1,4 @@
window.Discourse.UserActionGroup = Discourse.Model.extend
push: (item)->
@items = [] unless @items
@items.push(item)

View File

@ -12,6 +12,14 @@
<p class='excerpt'>
{{{unbound excerpt}}}
</p>
{{#each children}}
<div class='child-actions'>
<i class="icon {{unbound icon}}"></i>
{{#each items}}
<a href="/users/{{unbound username}}" class='avatar-link'><div class='avatar-wrapper'>{{avatar this imageSize="tiny" extraClasses="actor" avatarTemplatePath="avatar_template" ignoreTitle="true"}}</div></a>
{{/each}}
</div>
{{/each}}
{{/with}}
{{/collection}}
</div>

View File

@ -268,6 +268,27 @@
font-size: 14px;
}
}
// styling of bottom section
#user-stream .child-actions {
margin-top: 8px;
.avatar-link {
float: none;
}
.icon {
width: 15px;
display: inline-block;
color: #777;
}
.avatar-wrapper {
border: none;
}
.avatar-link {
margin-right: 3px;
}
}
@include medium-width {
#user-stream {
width: 725px;
@ -277,4 +298,4 @@
#user-stream {
width: 680px;
}
}
}

View File

@ -131,7 +131,6 @@ class PostAlertObserver < ActiveRecord::Observer
exclude_user_ids << extract_mentioned_users(post).map{|u| u.id}
exclude_user_ids << extract_quoted_users(post).map{|u| u.id}
exclude_user_ids.flatten!
TopicUser.where(topic_id: post.topic_id, notification_level: TopicUser::NotificationLevel::WATCHING).includes(:user).each do |tu|
create_notification(tu.user, Notification.Types[:posted], post) unless exclude_user_ids.include?(tu.user_id)
end

View File

@ -18,7 +18,7 @@ class TopicUser < ActiveRecord::Base
end
def self.auto_track(user_id, topic_id, reason)
if exec_sql("select 1 from topic_users where user_id = ? and topic_id = ? and notifications_reason_id is null", user_id, topic_id).count == 1
if TopicUser.where(user_id: user_id, topic_id: topic_id, notifications_reason_id: nil).exists?
self.change(user_id, topic_id,
notification_level: NotificationLevel::TRACKING,
notifications_reason_id: reason

View File

@ -3,6 +3,7 @@ require_dependency 'sql_builder'
class UserAction < ActiveRecord::Base
belongs_to :user
belongs_to :target_post, :class_name => "Post"
attr_accessible :acting_user_id, :action_type, :target_topic_id, :target_post_id, :target_user_id, :user_id
validates_presence_of :action_type
@ -15,7 +16,6 @@ class UserAction < ActiveRecord::Base
POST = 5
RESPONSE= 6
MENTION = 7
TOPIC_RESPONSE = 8
QUOTE = 9
STAR = 10
EDIT = 11
@ -29,7 +29,6 @@ class UserAction < ActiveRecord::Base
NEW_TOPIC,
POST,
RESPONSE,
TOPIC_RESPONSE,
LIKE,
WAS_LIKED,
MENTION,
@ -39,23 +38,19 @@ class UserAction < ActiveRecord::Base
].each_with_index.to_a.flatten]
def self.stats(user_id, guardian)
sql = <<SQL
select action_type, count(*) count
from user_actions
where user_id = ?
group by action_type
SQL
results = self.exec_sql(sql, user_id).to_a
results = UserAction.select("action_type, COUNT(*) count, '' description")
.where(user_id: user_id)
.group('action_type')
.to_a
# should push this into the sql at some point, but its simple enough for now
unless guardian.can_see_private_messages?(user_id)
results.reject!{|a| [GOT_PRIVATE_MESSAGE, NEW_PRIVATE_MESSAGE].include?(a["action_type"].to_i)}
results.reject!{|a| [GOT_PRIVATE_MESSAGE, NEW_PRIVATE_MESSAGE].include?(a.action_type)}
end
results.sort!{|a,b| ORDER[a["action_type"].to_i] <=> ORDER[b["action_type"].to_i]}
results.sort!{|a,b| ORDER[a.action_type] <=> ORDER[b.action_type]}
results.each do |row|
row["description"] = self.description(row["action_type"], detailed: true)
row.description = self.description(row.action_type, detailed: true)
end
results
@ -74,14 +69,22 @@ SQL
guardian = opts[:guardian]
ignore_private_messages = opts[:ignore_private_messages]
# The weird thing is that target_post_id can be null, so it makes everything
# ever so more complex. Should we allow this, not sure.
builder = SqlBuilder.new("
select t.title, a.action_type, a.created_at,
t.id topic_id, coalesce(p.post_number, 1) post_number, u.email ,u.username, u.name, u.id user_id, coalesce(p.cooked, p2.cooked) cooked
from user_actions as a
join topics t on t.id = a.target_topic_id
left join posts p on p.id = a.target_post_id
left join users u on u.id = a.acting_user_id
left join posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
SELECT
t.title, a.action_type, a.created_at, t.id topic_id,
coalesce(p.post_number, 1) post_number,
pu.email ,pu.username, pu.name, pu.id user_id,
u.email acting_email, u.username acting_username, u.name acting_name, u.id acting_user_id,
coalesce(p.cooked, p2.cooked) cooked
FROM user_actions as a
JOIN topics t on t.id = a.target_topic_id
LEFT JOIN posts p on p.id = a.target_post_id
JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
JOIN users u on u.id = a.acting_user_id
JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id)
/*where*/
/*order_by*/
/*offset*/
@ -109,13 +112,16 @@ left join posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
end
data.each do |row|
row["action_type"] = row["action_type"].to_i
row["description"] = self.description(row["action_type"])
row["created_at"] = DateTime.parse(row["created_at"])
# we should probably cache the excerpts in the db at some point
row["excerpt"] = PrettyText.excerpt(row["cooked"],300) if row["cooked"]
row["cooked"] = nil
row["avatar_template"] = User.avatar_template(row["email"])
row["acting_avatar_template"] = User.avatar_template(row["acting_email"])
row.delete("email")
row.delete("acting_email")
row["slug"] = Slug.for(row["title"])
end
@ -137,8 +143,6 @@ left join posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
t[:likes_given]
when RESPONSE
t[:responses]
when TOPIC_RESPONSE
t[:topic_responses]
when POST
t[:posts]
when MENTION
@ -161,7 +165,7 @@ left join posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
then t[:posted]
when LIKE,WAS_LIKED
then t[:liked]
when RESPONSE, TOPIC_RESPONSE,POST
when RESPONSE,POST
then t[:responded_to]
when BOOKMARK
then t[:bookmarked]

View File

@ -110,21 +110,6 @@ class UserActionObserver < ActiveRecord::Observer
UserAction.remove_action!(row)
end
end
return if model.topic.private_message?
# a bit odd but we may have stray records
if model.topic and model.topic.user_id != model.user_id
row[:action_type] = UserAction::TOPIC_RESPONSE
row[:user_id] = model.topic.user_id
if model.deleted_at.nil?
UserAction.log_action!(row)
else
UserAction.remove_action!(row)
end
end
end
def log_topic(model)

View File

@ -460,6 +460,8 @@ en:
saving: "Saving..."
saved: "Saved!"
user_action_descriptions:
"6": "Responses"
user:
information: "User Information"
profile: Profile

View File

@ -0,0 +1,14 @@
class RemoveTopicResponseActions < ActiveRecord::Migration
def up
# 2 notes:
# migrations should never use the object model to run sql, otherwise they are a time bomb
# this action type is not valid, we log a "response" action type anyway due to the watch implementation, its a relic.
#
# There is an open question about we should keep stuff in the user stream on the user page, even if a topic is unwatched
# Eg: I am not watching a topic I created, when somebody responds to the topic should I be notified on the user page?
execute 'delete from user_actions where action_type = 8'
end
def down
end
end

File diff suppressed because it is too large Load Diff

21
spec/javascripts/hacks.js Normal file
View File

@ -0,0 +1,21 @@
// hacks for ember, this sets up our app for testing
(function(){
var currentWindowOnload = window.onload;
window.onload = function() {
if (currentWindowOnload) {
currentWindowOnload();
}
$('<div id="main"><div class="rootElement"></div></div>').appendTo($('body')).hide();
Discourse.SiteSettings = {}
Discourse.Router.map(function() {
this.route("jasmine",{path: "/jasmine"});
Discourse.routeBuilder.apply(this)
});
}
})()

View File

@ -1,6 +1,6 @@
describe "Discourse.MessageBus", ->
describe "Web Sockets", ->
describe "Long polling", ->
bus = Discourse.MessageBus
bus.start()

View File

@ -9,7 +9,7 @@ describe "PreloadStore", ->
expect(PreloadStore.contains('joker')).toBe(false)
it "returns true for a stored key", ->
expect(PreloadStore.contains('bane')).toBe(true)
expect(PreloadStore.contains('bane')).toBe(true)
describe 'getStatic', ->
@ -17,7 +17,7 @@ describe "PreloadStore", ->
expect(PreloadStore.getStatic('joker')).toBe(undefined)
it "returns the the key if it exists", ->
expect(PreloadStore.getStatic('bane')).toBe('evil')
expect(PreloadStore.getStatic('bane')).toBe('evil')
it "removes the key after being called", ->
PreloadStore.getStatic('bane')

View File

@ -1,11 +1,46 @@
//= require env
//= require jquery
//= require external/jquery.ui.widget.js
//= require external/handlebars-1.0.rc.2.js
//= require ../../app/assets/javascripts/preload_store.js
//= require preload_store
// probe framework first
//= require ../../app/assets/javascripts/discourse/components/probes.js
// Externals we need to load first
//= require ../../app/assets/javascripts/external/jquery-1.8.2.js
//= require ../../app/assets/javascripts/external/jquery.ui.widget.js
//= require ../../app/assets/javascripts/external/handlebars-1.0.rc.2.js
//= require ../../app/assets/javascripts/external/ember.js
// Pagedown customizations
//= require ../../app/assets/javascripts/pagedown_custom.js
// The rest of the externals
//= require_tree ../../app/assets/javascripts/external
//= require i18n
//= require ../../app/assets/javascripts/discourse/translations
//= require ../../app/assets/javascripts/discourse/helpers/i18n_helpers
//= require ../../app/assets/javascripts/discourse
// Stuff we need to load first
//= require_tree ../../app/assets/javascripts/discourse/mixins
//= require ../../app/assets/javascripts/discourse/components/debounce
//= require ../../app/assets/javascripts/discourse/views/view
//= require ../../app/assets/javascripts/discourse/controllers/controller
//= require ../../app/assets/javascripts/discourse/views/modal/modal_body_view
//= require ../../app/assets/javascripts/discourse/models/model
//= require ../../app/assets/javascripts/discourse/routes/discourse_route
//= require_tree ../../app/assets/javascripts/discourse/controllers
//= require_tree ../../app/assets/javascripts/discourse/components
//= require_tree ../../app/assets/javascripts/discourse/models
//= require_tree ../../app/assets/javascripts/discourse/views
//= require_tree ../../app/assets/javascripts/discourse/helpers
//= require_tree ../../app/assets/javascripts/discourse/templates
//= require_tree ../../app/assets/javascripts/discourse/routes
//= require_tree .
//= require hacks

View File

@ -0,0 +1,16 @@
describe "Discourse.UserAction", ->
describe "collapseStream", ->
it "collapses all likes", ->
actions = [
Discourse.UserAction.create(action_type: Discourse.UserAction.LIKE, topic_id:1, user_id:1, post_number:1)
Discourse.UserAction.create(action_type: Discourse.UserAction.EDIT, topic_id:2, user_id:1, post_number:1)
Discourse.UserAction.create(action_type: Discourse.UserAction.LIKE, topic_id:1, user_id:2, post_number:1)
]
actions = Discourse.UserAction.collapseStream(actions)
expect(actions.length).toBe(2)
expect(actions[0].get("children").length).toBe(1)
expect(actions[0].get("children")[0].items.length).toBe(2)

View File

@ -111,7 +111,6 @@ describe UserAction do
end
end
it 'should not log a post user action' do
@post.user.user_actions.where(action_type: UserAction::POST).first.should be_nil
end
@ -121,7 +120,7 @@ describe UserAction do
before do
@other_user = Fabricate(:coding_horror)
@mentioned = Fabricate(:admin)
@response = Fabricate(:post, topic: @post.topic, user: @other_user, raw: "perhaps @#{@mentioned.username} knows how this works?")
@response = Fabricate(:post, reply_to_post_number: 1, topic: @post.topic, user: @other_user, raw: "perhaps @#{@mentioned.username} knows how this works?")
end
it 'should log a post action for the poster' do
@ -129,7 +128,7 @@ describe UserAction do
end
it 'should log a post action for the original poster' do
@post.user.user_actions.where(action_type: UserAction::TOPIC_RESPONSE).first.should_not be_nil
@post.user.user_actions.where(action_type: UserAction::RESPONSE).first.should_not be_nil
end
it 'should log a mention for the mentioned' do
@ -142,6 +141,10 @@ describe UserAction do
@response.user.user_actions.where(action_type: UserAction::POST).count.should == 1
end
it 'should not log topic reply and reply for a single post' do
@post.user.user_actions.joins(:target_post).where('posts.post_number = 2').count.should == 1
end
end
end