Commit 17673f21873397a15449f70589758298b338621f

Authored by Caio Almeida
Committed by Antonio Terceiro
1 parent 8c237770

Nested comments

(ActionItem1771)
app/controllers/public/content_viewer_controller.rb
... ... @@ -99,7 +99,7 @@ class ContentViewerController < ApplicationController
99 99 @images = @images.paginate(:per_page => per_page, :page => params[:npage]) unless params[:slideshow]
100 100 end
101 101  
102   - @comments = @page.comments(true)
  102 + @comments = @page.comments(true).as_thread
103 103 if params[:slideshow]
104 104 render :action => 'slideshow', :layout => 'slideshow'
105 105 end
... ... @@ -116,7 +116,7 @@ class ContentViewerController < ApplicationController
116 116 @comment = nil # clear the comment form
117 117 redirect_to :action => 'view_page', :profile => params[:profile], :page => @page.explode_path, :view => params[:view]
118 118 else
119   - @form_div = 'opened'
  119 + @form_div = 'opened' if params[:comment][:reply_of_id].blank?
120 120 end
121 121 end
122 122  
... ...
app/models/comment.rb
... ... @@ -5,6 +5,8 @@ class Comment < ActiveRecord::Base
5 5 validates_presence_of :title, :body
6 6 belongs_to :article, :counter_cache => true
7 7 belongs_to :author, :class_name => 'Person', :foreign_key => 'author_id'
  8 + has_many :children, :class_name => 'Comment', :foreign_key => 'reply_of_id', :dependent => :destroy
  9 + belongs_to :reply_of, :class_name => 'Comment', :foreign_key => 'reply_of_id'
8 10  
9 11 # unauthenticated authors:
10 12 validates_presence_of :name, :if => (lambda { |record| !record.email.blank? })
... ... @@ -46,7 +48,7 @@ class Comment < ActiveRecord::Base
46 48 end
47 49  
48 50 def message
49   - author_id ? _('(removed user)') : ('<br />' + _('(unauthenticated user)'))
  51 + author_id ? _('(removed user)') : _('(unauthenticated user)')
50 52 end
51 53  
52 54 def removed_user_image
... ... @@ -73,6 +75,25 @@ class Comment &lt; ActiveRecord::Base
73 75 end
74 76 end
75 77  
  78 + def replies
  79 + @replies || children
  80 + end
  81 +
  82 + def replies=(comments_list)
  83 + @replies = comments_list
  84 + end
  85 +
  86 + def self.as_thread
  87 + result = {}
  88 + root = []
  89 + all.each do |c|
  90 + c.replies = []
  91 + result[c.id] ||= c
  92 + c.reply_of_id.nil? ? root << c : result[c.reply_of_id].replies << c
  93 + end
  94 + root
  95 + end
  96 +
76 97 class Notifier < ActionMailer::Base
77 98 def mail(comment)
78 99 profile = comment.article.profile
... ...
app/views/content_viewer/_comment.rhtml
1   -<%= content_tag('a', '', :name => comment.anchor) %>
2   -<div class="article-comment<%= ' comment-from-owner' if ( comment.author && (@page.profile.name == comment.author.name) ) %> comment-logged-<%= comment.author ? 'in' : 'out' %>">
  1 +<li id="<%= comment.anchor %>" class="article-comment">
  2 + <div class="article-comment-inner">
  3 +
  4 + <div class="comment-content comment-logged-<%= comment.author ? 'in' : 'out' %> <%= 'comment-from-owner' if ( comment.author && (@page.profile.name == comment.author.name) ) %>">
3 5  
4 6 <% if comment.author %>
5   - <%= link_to content_tag( 'span', comment.author_name, :class => 'comment-info' ), comment.author.url,
  7 + <%= link_to image_tag(profile_icon(comment.author, :minor)) +
  8 + content_tag('span', comment.author_name, :class => 'comment-info'),
  9 + comment.author.url,
6 10 :class => 'comment-picture',
7   - :style => 'background-image:url(%s)' % profile_icon(comment.author, :minor)
  11 + :title => comment.author_name
8 12 %>
9 13 <% else %>
10 14 <%# unauthenticated user: display gravatar icon %>
11   - <% url_image = comment.author_id ? comment.removed_user_image : str_gravatar_url_for( comment.email, :size => 50 ) %>
12   - <%= content_tag 'span', content_tag('span', comment.author_name + comment.message, :class => 'comment-info'),
  15 + <% url_image, status_class = comment.author_id ? [comment.removed_user_image, 'icon-user-removed'] : [str_gravatar_url_for( comment.email, :size => 50 ), 'icon-user-unknown'] %>
  16 + <%= content_tag 'span', image_tag(url_image) +
  17 + content_tag('span', comment.author_name, :class => 'comment-info') +
  18 + content_tag('span', comment.message, :class => 'comment-user-status ' + status_class),
13 19 :class => 'comment-picture',
14   - :style => 'background-image:url(%s)' % url_image
  20 + :title => '%s %s' % [comment.author_name, comment.message]
15 21 %>
16 22 <% end %>
17 23  
... ... @@ -32,6 +38,35 @@
32 38 <%= txt2html comment.body %>
33 39 </div>
34 40 </div>
  41 +
  42 + <div class="comment_reply post_comment_box closed">
  43 + <% if @comment && @comment.errors.any? && @comment.reply_of_id.to_i == comment.id %>
  44 + <%= error_messages_for :comment %>
  45 + <script type="text/javascript">
  46 + jQuery(function() {
  47 + document.location.href = '#<%= comment.anchor %>';
  48 + add_comment_reply_form('#comment-reply-to-<%= comment.id %>', <%= comment.id %>);
  49 + });
  50 + </script>
  51 + <% end %>
  52 + <%= link_to_function _('Reply'),
  53 + "var f = add_comment_reply_form(this, %s); f.find('input[name=comment[title]], textarea').val(''); return false" % comment.id,
  54 + :class => 'comment-reply-link',
  55 + :id => 'comment-reply-to-' + comment.id.to_s
  56 + %>
  57 + </div>
  58 +
  59 + <% end %>
  60 +
  61 + </div>
  62 +
  63 + <% unless comment.replies.blank? %>
  64 + <ul class="comment-replies">
  65 + <% comment.replies.each do |reply| %>
  66 + <%= render :partial => 'comment', :locals => { :comment => reply } %>
  67 + <% end %>
  68 + </ul>
35 69 <% end %>
36 70  
37   -</div>
  71 + </div>
  72 +</li>
... ...
app/views/content_viewer/_comment_form.rhtml
1   -<%
2   - comment_form_id = 'comment_form'+ rand(9999).to_s
3   -%>
4   -
5 1 <% focus_on = logged_in? ? 'title' : 'name' %>
6 2  
7   -<%= error_messages_for :comment %>
  3 +<% if @comment && @comment.errors.any? && @comment.reply_of_id.blank? %>
  4 + <%= error_messages_for :comment %>
  5 + <script type="text/javascript">jQuery(function() { document.location.href = '#page-comment-form'; });</script>
  6 +<% end %>
8 7  
9 8 <% @form_div ||= 'closed' %>
10 9  
11   -<div
12   - class="post_comment_box <%= @form_div %>"
13   - onclick="f=$('<%= comment_form_id %>');
14   - this.className = this.className.replace(/closed/,'opened');
15   - f.commit.focus(); f['comment[<%= focus_on %>]'].focus();
16   - this.onclick=null">
  10 +<div class="post_comment_box <%= @form_div %>">
17 11  
18   -<h4><%= content_tag('a', '', :name => 'comment_form') + _('Post a comment') %></h4>
  12 +<h4 onclick="var d = jQuery(this).parent('.post_comment_box');
  13 + if (d.hasClass('closed')) {
  14 + d.removeClass('closed');
  15 + d.addClass('opened');
  16 + d.find('input[name=comment[title]], textarea').val('');
  17 + d.find('.comment_form input[name=comment[<%= focus_on %>]]').focus();
  18 + }">
  19 + <%= content_tag('a', '', :name => 'comment_form') + _('Post a comment') %>
  20 +</h4>
19 21  
20   -<% form_tag( url_for(@page.view_url.merge({:only_path => true})), { :id => comment_form_id } ) do %>
  22 +<% form_tag( url_for(@page.view_url.merge({:only_path => true})), { :class => 'comment_form' } ) do %>
21 23 <%= icaptcha_field() %>
22 24 <%= hidden_field_tag(:confirm, 'false') %>
23 25  
... ... @@ -37,7 +39,8 @@
37 39 <%= required labelled_form_field(_('Title'), text_field(:comment, :title)) %>
38 40 <%= required labelled_form_field(_('Enter your comment'), text_area(:comment, :body, :rows => 5)) %>
39 41 <% button_bar do %>
40   - <%= submit_button('add', _('Post comment'), :onclick => "$('confirm').value = 'true'; this.disabled = true; this.form.submit(); return true;") %>
  42 + <%= submit_button('add', _('Post comment'), :onclick => "this.form.confirm.value = 'true'; this.disabled = true; this.form.submit(); return true;") %>
  43 + <%= button_to_function :cancel, _('Cancel'), "f=jQuery(this).parents('.post_comment_box'); f.removeClass('opened'); f.addClass('closed'); return false" %>
41 44 <% end %>
42 45 <% end %>
43 46  
... ...
app/views/content_viewer/view_page.rhtml
... ... @@ -120,14 +120,15 @@
120 120 </div>
121 121 <% end %>
122 122  
123   -<div class="comments">
124   - <a name="comments_list"></a>
  123 +<div class="comments" id="comments_list">
125 124 <% if @page.accept_comments? %>
126 125 <h3 <%= 'class="no-comments-yet"' if @comments.size == 0 %>>
127 126 <%= number_of_comments(@page) %>
128 127 </h3>
129   - <%= render :partial => 'comment', :collection => @comments %>
130   - <%= render :partial => 'comment_form' %>
  128 + <ul class="article-comments-list">
  129 + <%= render :partial => 'comment', :collection => @comments %>
  130 + </ul>
  131 + <div id="page-comment-form"><%= render :partial => 'comment_form' %></div>
131 132 <% end %>
132 133 </div><!-- end class="comments" -->
133 134  
... ...
db/migrate/20101221134544_add_reply_to_comments.rb 0 → 100644
... ... @@ -0,0 +1,9 @@
  1 +class AddReplyToComments < ActiveRecord::Migration
  2 + def self.up
  3 + add_column :comments, :reply_of_id, :integer
  4 + end
  5 +
  6 + def self.down
  7 + remove_column :comments, :reply_of_id
  8 + end
  9 +end
... ...
features/comment_reply.feature 0 → 100644
... ... @@ -0,0 +1,90 @@
  1 +Feature: comment
  2 + As a visitor
  3 + I want to reply comments
  4 +
  5 + Background:
  6 + Given the following users
  7 + | login |
  8 + | booking |
  9 + And the following articles
  10 + | owner | name |
  11 + | booking | article to comment |
  12 + | booking | another article |
  13 + And the following comments
  14 + | article | author | title | body |
  15 + | article to comment | booking | root comment | this comment is not a reply |
  16 + | another article | booking | some comment | this is my very own comment |
  17 +
  18 + Scenario: not post a comment without javascript
  19 + Given I am on /booking/article-to-comment
  20 + When I follow "Reply" within ".comment-balloon"
  21 + Then I should not see "Enter your comment" within "div.comment-balloon"
  22 +
  23 + Scenario: not show any reply form by default
  24 + When I go to /booking/article-to-comment
  25 + Then I should not see "Enter your comment" within "div.comment-balloon"
  26 + And I should see "Reply" within "div.comment-balloon"
  27 +
  28 + @selenium
  29 + Scenario: show error messages when make a blank comment reply
  30 + Given I am logged in as "booking"
  31 + And I go to /booking/article-to-comment
  32 + And I follow "Reply" within ".comment-balloon"
  33 + When I press "Post comment" within ".comment-balloon"
  34 + Then I should see "Title can't be blank" within "div.comment_reply"
  35 + And I should see "Body can't be blank" within "div.comment_reply"
  36 +
  37 + @selenium
  38 + Scenario: not show any reply form by default
  39 + When I go to /booking/article-to-comment
  40 + Then I should not see "Enter your comment" within "div.comment-balloon"
  41 + And I should see "Reply" within "div.comment-balloon"
  42 +
  43 + @selenium
  44 + Scenario: render reply form
  45 + Given I am on /booking/article-to-comment
  46 + When I follow "Reply" within ".comment-balloon"
  47 + Then I should see "Enter your comment" within "div.comment_reply.opened"
  48 +
  49 + @selenium
  50 + Scenario: cancel comment reply
  51 + Given I am on /booking/article-to-comment
  52 + When I follow "Reply" within ".comment-balloon"
  53 + And I follow "Cancel" within ".comment-balloon"
  54 + Then I should see "Enter your comment" within "div.comment_reply.closed"
  55 +
  56 + @selenium
  57 + Scenario: not render same reply form twice
  58 + Given I am on /booking/article-to-comment
  59 + When I follow "Reply" within ".comment-balloon"
  60 + And I follow "Cancel" within ".comment-balloon"
  61 + And I follow "Reply" within ".comment-balloon"
  62 + Then there should be 1 "comment_form" within "comment_reply"
  63 + And I should see "Enter your comment" within "div.comment_reply.opened"
  64 +
  65 + @selenium
  66 + Scenario: reply a comment
  67 + Given I am logged in as "booking"
  68 + And I go to /booking/another-article
  69 + And I follow "Reply" within ".comment-balloon"
  70 + And I fill in "Title" within "comment-balloon" with "Hey ho, let's go!"
  71 + And I fill in "Enter your comment" within "comment-balloon" with "Hey ho, let's go!"
  72 + When I press "Post comment" within ".comment-balloon"
  73 + Then I should see "Hey ho, let's go" within "ul.comment-replies"
  74 + And there should be 1 "comment-replies" within "article-comment"
  75 +
  76 + @selenium
  77 + Scenario: redirect to right place after reply a picture comment
  78 + Given the following files
  79 + | owner | file | mime |
  80 + | booking | rails.png | image/png |
  81 + And the following comment
  82 + | article | author | title | body |
  83 + | rails.png | booking | root comment | this comment is not a reply |
  84 + Given I am logged in as "booking"
  85 + And I go to /booking/rails.png?view=true
  86 + And I follow "Reply" within ".comment-balloon"
  87 + And I fill in "Title" within "comment-balloon" with "Hey ho, let's go!"
  88 + And I fill in "Enter your comment" within "comment-balloon" with "Hey ho, let's go!"
  89 + When I press "Post comment" within ".comment-balloon"
  90 + Then I should be exactly on /booking/rails.png?view=true
... ...
features/step_definitions/noosfero_steps.rb
... ... @@ -307,3 +307,13 @@ Given /^the articles of &quot;(.+)&quot; are moderated$/ do |organization|
307 307 organization.moderated_articles = true
308 308 organization.save
309 309 end
  310 +
  311 +Given /^the following comments?$/ do |table|
  312 + table.hashes.each do |item|
  313 + data = item.dup
  314 + article = Article.find_by_name(data.delete("article"))
  315 + author = Profile[data.delete("author")]
  316 + comment = article.comments.build(:author => author, :title => data.delete("title"), :body => data.delete("body"))
  317 + comment.save!
  318 + end
  319 +end
... ...
features/step_definitions/selenium_steps.rb
... ... @@ -66,6 +66,20 @@ When /^I select window &quot;([^\&quot;]*)&quot;$/ do |selector|
66 66 selenium.select_window(selector)
67 67 end
68 68  
  69 +When /^I fill in "([^\"]*)" within "([^\"]*)" with "([^\"]*)"$/ do |field_label, parent_class, value|
  70 + selenium.type("xpath=//*[contains(@class, '#{parent_class}')]//*[@id=//label[contains(., '#{field_label}')]/@for]", value)
  71 +end
  72 +
  73 +When /^I press "([^\"]*)" within "([^\"]*)"$/ do |button_value, selector|
  74 + selenium.click("css=#{selector} input[value=#{button_value}]")
  75 + selenium.wait_for_page_to_load(10000)
  76 +end
  77 +
  78 +Then /^there should be ([1-9][0-9]*) "([^\"]*)" within "([^\"]*)"$/ do |number, child_class, parent_class|
  79 + # Using xpath is the only way to count
  80 + response.selenium.get_xpath_count("//*[contains(@class,'#{parent_class}')]//*[contains(@class,'#{child_class}')]").to_i.should be(number.to_i)
  81 +end
  82 +
69 83 #### Noosfero specific steps ####
70 84  
71 85 Then /^the select for category "([^\"]*)" should be visible$/ do |name|
... ...
features/step_definitions/webrat_steps.rb
... ... @@ -125,8 +125,12 @@ Then /^I should see &quot;([^\&quot;]*)&quot;$/ do |text|
125 125 end
126 126  
127 127 Then /^I should see "([^\"]*)" within "([^\"]*)"$/ do |text, selector|
128   - within(selector) do |content|
129   - content.should contain(text)
  128 + if response.class.to_s == 'Webrat::SeleniumResponse'
  129 + response.selenium.text('css=' + selector).should include(text)
  130 + else
  131 + within(selector) do |content|
  132 + content.should contain(text)
  133 + end
130 134 end
131 135 end
132 136  
... ... @@ -147,8 +151,12 @@ Then /^I should not see &quot;([^\&quot;]*)&quot;$/ do |text|
147 151 end
148 152  
149 153 Then /^I should not see "([^\"]*)" within "([^\"]*)"$/ do |text, selector|
150   - within(selector) do |content|
151   - content.should_not contain(text)
  154 + if response.class.to_s == 'Webrat::SeleniumResponse'
  155 + response.selenium.text('css=' + selector).should_not include(text)
  156 + else
  157 + within(selector) do |content|
  158 + content.should_not contain(text)
  159 + end
152 160 end
153 161 end
154 162  
... ... @@ -187,4 +195,3 @@ end
187 195 Then /^show me the page$/ do
188 196 save_and_open_page
189 197 end
190   -
... ...
public/designs/icons/tango/style.css
... ... @@ -84,3 +84,5 @@
84 84 .icon-gallery { background-image: url(Tango/16x16/mimetypes/image-x-generic.png) }
85 85 .icon-newgallery { background-image: url(Tango/16x16/mimetypes/image-x-generic.png) }
86 86 .icon-locale { background-image: url(Tango/16x16/apps/preferences-desktop-locale.png) }
  87 +.icon-user-removed { background-image: url(Tango/16x16/actions/gtk-cancel.png) }
  88 +.icon-user-unknown { background-image: url(Tango/16x16/status/dialog-error.png) }
... ...
public/designs/themes/base/style.css
... ... @@ -1111,22 +1111,22 @@ hr.pre-posts, hr.sep-posts {
1111 1111 background: url(imgs/comment-owner-bg-NO.png) 0% 0% no-repeat;
1112 1112 }
1113 1113  
1114   -
1115 1114 .comment-created-at {
1116 1115 position: relative;
1117   - padding-right: 10px;
  1116 + padding-right: 9px;
1118 1117 }
  1118 +
1119 1119 .comment-from-owner .comment-created-at {
1120 1120 color: #333;
1121 1121 }
1122 1122  
1123   -
1124 1123 .article-comment .button-bar {
1125 1124 position: relative;
1126   - top: 3px;
1127   - right: 1px;
  1125 + top: 9px;
  1126 + right: 8px;
1128 1127 z-index: 10px;
1129 1128 }
  1129 +
1130 1130 .article-comment .button-bar a {
1131 1131 position: relative;
1132 1132 }
... ... @@ -1136,8 +1136,6 @@ hr.pre-posts, hr.sep-posts {
1136 1136 padding: 7px 12px 3px 26px;
1137 1137 }
1138 1138  
1139   -
1140   -
1141 1139 /* ==> controllers.css <== */
1142 1140  
1143 1141 /******** controller-friends action-friends-index ********/
... ...
public/images/black-alpha-pixel-5.png 0 → 100644

178 Bytes

public/images/comment-reply-owner-bg.png 0 → 100644

308 Bytes

public/javascripts/application.js
... ... @@ -649,3 +649,20 @@ jQuery(function($) {
649 649 })
650 650 }
651 651 });
  652 +
  653 +function add_comment_reply_form(button, comment_id) {
  654 + var container = jQuery(button).parents('.comment_reply');
  655 + var f = container.find('.comment_form');
  656 + if (f.length == 0) {
  657 + f = jQuery('#page-comment-form .comment_form').clone();
  658 + f.find('.fieldWithErrors').map(function() { jQuery(this).replaceWith(jQuery(this).contents()); });
  659 + f.prepend('<input type="hidden" name="comment[reply_of_id]" value="' + comment_id + '" />');
  660 + container.append(f);
  661 + }
  662 + if (container.hasClass('closed')) {
  663 + container.removeClass('closed');
  664 + container.addClass('opened');
  665 + container.find('.comment_form input[type=text]:visible:first').focus();
  666 + }
  667 + return f;
  668 +}
... ...
public/stylesheets/application.css
... ... @@ -1025,11 +1025,6 @@ code input {
1025 1025 padding: 4px;
1026 1026 }
1027 1027  
1028   -.comment-from-owner {
1029   - border: 1px solid #888;
1030   - background: #eee;
1031   -}
1032   -
1033 1028 #article .article-comment h4 {
1034 1029 font-size: 13px;
1035 1030 margin: 0px;
... ... @@ -1042,7 +1037,6 @@ code input {
1042 1037 width: 70px;
1043 1038 background-repeat: no-repeat;
1044 1039 background-position: 0% 0%;
1045   - padding-top: 50px;
1046 1040 }
1047 1041  
1048 1042 a.comment-picture {
... ... @@ -1061,8 +1055,8 @@ a.comment-picture {
1061 1055 display: inline;
1062 1056 }
1063 1057  
1064   -.comment-details {
1065   - margin-left: 100px;
  1058 +.comment-info {
  1059 + display: block;
1066 1060 }
1067 1061  
1068 1062 .comment-from-owner .comment-info {
... ... @@ -1071,12 +1065,265 @@ a.comment-picture {
1071 1065  
1072 1066 .comment-text {
1073 1067 font-size: 11px;
  1068 + padding-right: 10px;
1074 1069 }
1075 1070  
1076 1071 .comment-logged-out .comment-text {
1077 1072 color: #888;
1078 1073 }
1079 1074  
  1075 +.comment-created-at {
  1076 + padding-right: 9px;
  1077 +}
  1078 +
  1079 +#content .comment-from-owner input.button,
  1080 +#content .comment-from-owner a.button {
  1081 + border-color: #646464;
  1082 +}
  1083 +
  1084 +.article-comment .button-bar {
  1085 + top: 9px;
  1086 + right: 8px;
  1087 +}
  1088 +
  1089 +.msie7 .article-comments-list .comment-balloon {
  1090 + margin-top: -15px;
  1091 +}
  1092 +
  1093 +.article-comments-list .comment-balloon br {
  1094 + line-height: 0;
  1095 +}
  1096 +
  1097 +.article-comments-list .comment-balloon div#errorExplanation h2 {
  1098 + margin-left: 20px;
  1099 + font-size: 14px;
  1100 +}
  1101 +
  1102 +.article-comments-list .comment-balloon div#errorExplanation p,
  1103 +.article-comments-list .comment-balloon div#errorExplanation ul,
  1104 +.article-comments-list .comment-balloon div#errorExplanation li {
  1105 + margin: 0;
  1106 + margin-left: 20px;
  1107 + text-align: left;
  1108 +}
  1109 +
  1110 +.article-comments-list .comment-balloon div#errorExplanation li {
  1111 + list-style: circle;
  1112 +}
  1113 +
  1114 +.comment-user-status {
  1115 + font-size: 9px;
  1116 + text-indent: -5000em;
  1117 + width: 16px;
  1118 + height: 16px;
  1119 + display: block;
  1120 + position: absolute;
  1121 + top: 33px;
  1122 + left: 33px;
  1123 + background-repeat: no-repeat;
  1124 +}
  1125 +
  1126 +#article .article-comments-list,
  1127 +#article .article-comments-list ul,
  1128 +#article .article-comments-list li {
  1129 + padding: 0;
  1130 + margin: 0;
  1131 + margin-bottom: 10px;
  1132 + list-style: none;
  1133 +}
  1134 +
  1135 +.article-comment .button-bar {
  1136 + margin: 0;
  1137 +}
  1138 +
  1139 +.article-comment .comment-details {
  1140 + margin: 0px;
  1141 + padding: 7px 1px 3px 26px;
  1142 +}
  1143 +
  1144 +#article .comment-reply-link {
  1145 + font-size: 10px;
  1146 + text-decoration: none;
  1147 + color: #000;
  1148 +}
  1149 +
  1150 +#article .comment-reply-link:hover {
  1151 + text-decoration: underline;
  1152 +}
  1153 +
  1154 +#article .opened .comment-reply-link {
  1155 + visibility: hidden;
  1156 +}
  1157 +
  1158 +.comment_form div.fieldWithErrors input {
  1159 + border-color: #999;
  1160 + border-width: 0 1px 1px 0;
  1161 + background: transparent url("../images/input-bg.gif") no-repeat top left;
  1162 +}
  1163 +
  1164 +.comment_form div.fieldWithErrors {
  1165 + background: transparent;
  1166 +}
  1167 +
  1168 +/* * * Comment Replies * * */
  1169 +
  1170 +.comment-replies .comment-wrapper-1 {
  1171 + margin-left: 45px;
  1172 +}
  1173 +
  1174 +.comment-replies .comment-wrapper-1,
  1175 +.comment-replies .comment-wrapper-2,
  1176 +.comment-replies .comment-wrapper-3,
  1177 +.comment-replies .comment-wrapper-4,
  1178 +.comment-replies .comment-wrapper-5,
  1179 +.comment-replies .comment-wrapper-6,
  1180 +.comment-replies .comment-wrapper-7,
  1181 +.comment-replies .comment-wrapper-8,
  1182 +.comment-replies .comment-from-owner .comment-wrapper-1,
  1183 +.comment-replies .comment-from-owner .comment-wrapper-2,
  1184 +.comment-replies .comment-from-owner .comment-wrapper-3,
  1185 +.comment-replies .comment-from-owner .comment-wrapper-4,
  1186 +.comment-replies .comment-from-owner .comment-wrapper-5,
  1187 +.comment-replies .comment-from-owner .comment-wrapper-6,
  1188 +.comment-replies .comment-from-owner .comment-wrapper-7,
  1189 +.comment-replies .comment-from-owner .comment-wrapper-8 {
  1190 + background: transparent;
  1191 +}
  1192 +
  1193 +.comment-replies .comment-from-owner.comment-content {
  1194 + background: transparent url(/images/comment-reply-owner-bg.png) left top repeat-x;
  1195 +}
  1196 +
  1197 +.comment-replies .comment-user-status {
  1198 + top: 15px;
  1199 + left: 15px;
  1200 +}
  1201 +
  1202 +#article .article-comments-list .comment-replies li {
  1203 + margin-bottom: 2px;
  1204 +}
  1205 +
  1206 +.comment-replies .comment-balloon div#errorExplanation h2 {
  1207 + margin-left: 0;
  1208 +}
  1209 +
  1210 +.comment-replies .comment-text,
  1211 +.comment-replies .comment-created-at {
  1212 + padding-right: 7px;
  1213 +}
  1214 +
  1215 +.comment-replies .comment-picture {
  1216 + width: 40px;
  1217 + height: 57px;
  1218 + overflow: hidden;
  1219 +}
  1220 +
  1221 +.comment-replies .comment-picture img {
  1222 + width: 32px;
  1223 + height: 32px;
  1224 +}
  1225 +
  1226 +.article-comment .comment-replies .button-bar {
  1227 + right: 7px;
  1228 + top: 2px;
  1229 +}
  1230 +
  1231 +.article-comment form .button-bar,
  1232 +.article-comment .comment-replies form .button-bar {
  1233 + right: 0;
  1234 + top: 0;
  1235 +}
  1236 +
  1237 +.comment-replies .comment-details {
  1238 + padding-top: 0;
  1239 + padding-left: 0;
  1240 +}
  1241 +
  1242 +#article .article-comments-list .comment-replies {
  1243 + padding-left: 74px;
  1244 + margin-top: 2px;
  1245 +}
  1246 +
  1247 +#article .comment-replies .comment-replies {
  1248 + padding-left: 10px;
  1249 +}
  1250 +
  1251 +#article .comment-replies .comment-replies .article-comment,
  1252 +#article .comment-replies .comment-replies .article-comment-inner {
  1253 + border-right: 0;
  1254 + margin-right: -1px;
  1255 + padding-right: 1px;
  1256 +}
  1257 +
  1258 +.comment-replies .comment-content {
  1259 + padding: 4px 0 0 4px;
  1260 + -moz-border-radius: 3px;
  1261 + -webkit-border-radius: 3px;
  1262 + border-radius: 3px;
  1263 +}
  1264 +
  1265 +#article .comment-replies .article-comment {
  1266 + border: 1px solid #808080;
  1267 + padding: 0;
  1268 + margin-bottom: 10px;
  1269 + background: transparent url(/images/black-alpha-pixel-5.png) left top repeat;
  1270 + -moz-border-radius: 5px;
  1271 + -webkit-border-radius: 5px;
  1272 + border-radius: 5px;
  1273 +}
  1274 +
  1275 +.comment-replies .article-comment-inner {
  1276 + border: 1px solid #fff;
  1277 + padding: 0;
  1278 + -moz-border-radius: 4px;
  1279 + -webkit-border-radius: 4px;
  1280 + border-radius: 4px;
  1281 +}
  1282 +
  1283 +#article .comment-replies .comment-replies .article-comment {
  1284 + -moz-border-radius: 5px 0 0 5px;
  1285 + -webkit-border-radius: 5px 0 0 5px;
  1286 + border-radius: 5px 0 0 5px;
  1287 +}
  1288 +
  1289 +.comment-replies .comment-replies .article-comment-inner {
  1290 + -moz-border-radius: 4px 0 0 4px;
  1291 + -webkit-border-radius: 4px 0 0 4px;
  1292 + border-radius: 4px 0 0 4px;
  1293 +}
  1294 +
  1295 +.comment-replies .comment-replies .comment-content {
  1296 + -moz-border-radius: 3px 0 0 3px;
  1297 + -webkit-border-radius: 3px 0 0 3px;
  1298 + border-radius: 3px 0 0 3px;
  1299 +}
  1300 +
  1301 +#content .comment-replies a.button,
  1302 +#content .comment-replies input.button {
  1303 + border-color: #808080;
  1304 +}
  1305 +
  1306 +.comment-replies .comment_reply {
  1307 + padding-right: 9px;
  1308 +}
  1309 +
  1310 +.comment-replies .comment-info,
  1311 +.comment-replies .comment-created-at,
  1312 +.comment-replies .comment-logged-out .comment-text,
  1313 +.comment-logged-out h4 {
  1314 + color: #000;
  1315 +}
  1316 +
  1317 +.comment-replies .comment-created-at {
  1318 + opacity: 0.4;
  1319 +}
  1320 +
  1321 +.comment-replies .comment-logged-out .comment-text,
  1322 +.comment-logged-out .comment-picture,
  1323 +.comment-logged-out h4 {
  1324 + opacity: 0.6;
  1325 +}
  1326 +
1080 1327 /* * * Comment Box * * */
1081 1328  
1082 1329 .post_comment_box {
... ... @@ -1107,6 +1354,10 @@ a.comment-picture {
1107 1354 margin: -10px 0px 0px 0px;
1108 1355 }
1109 1356  
  1357 +.post_comment_box.opened form {
  1358 + padding-bottom: 15px;
  1359 +}
  1360 +
1110 1361 .post_comment_box .formfield * {
1111 1362 width: 99%;
1112 1363 }
... ... @@ -1117,7 +1368,7 @@ a.comment-picture {
1117 1368  
1118 1369 .post_comment_box input.button {
1119 1370 position: relative;
1120   - float: none;
  1371 + float: left;
1121 1372 margin: auto;
1122 1373 }
1123 1374  
... ... @@ -1133,6 +1384,30 @@ a.comment-picture {
1133 1384 display: block;
1134 1385 }
1135 1386  
  1387 +.post_comment_box.comment_reply {
  1388 + margin: 0;
  1389 + text-align: right;
  1390 + padding: 0 11px 5px 0;
  1391 +}
  1392 +
  1393 +.comment_reply.post_comment_box.opened {
  1394 + background: transparent;
  1395 +}
  1396 +
  1397 +.comment_reply.post_comment_box form {
  1398 + margin: 0;
  1399 + padding-bottom: 27px;
  1400 + padding-left: 26px;
  1401 +}
  1402 +
  1403 +.comment-replies .comment_reply.post_comment_box form {
  1404 + padding-left: 0;
  1405 +}
  1406 +
  1407 +.post_comment_box.comment_reply #comment_title {
  1408 + width: 100%;
  1409 +}
  1410 +
1136 1411 /* * * addThis button * * */
1137 1412  
1138 1413 .msie #addThis {
... ...
test/factories.rb
... ... @@ -431,4 +431,9 @@ module Noosfero::Factory
431 431 { :name => 'Sender', :email => 'sender@example.com', :article_name => 'Some title', :article_body => 'some body text', :article_abstract => 'some abstract text'}
432 432 end
433 433  
  434 + def defaults_for_comment(params = {})
  435 + name = "comment_#{rand(1000)}"
  436 + { :title => name, :body => "my own comment", :article_id => 1 }.merge(params)
  437 + end
  438 +
434 439 end
... ...
test/functional/content_viewer_controller_test.rb
... ... @@ -242,7 +242,7 @@ class ContentViewerControllerTest &lt; Test::Unit::TestCase
242 242  
243 243 get :view_page, :profile => profile.identifier, :page => [ 'myarticle' ]
244 244  
245   - assert_tag :tag => 'form', :attributes => { :id => /^comment_form/, :action => '/person/article' }
  245 + assert_tag :tag => 'form', :attributes => { :class => /^comment_form/, :action => '/person/article' }
246 246 end
247 247  
248 248 should "display current article's tags" do
... ... @@ -928,7 +928,7 @@ class ContentViewerControllerTest &lt; Test::Unit::TestCase
928 928  
929 929 get :view_page, :profile => profile.identifier, :page => article.explode_path
930 930  
931   - assert_tag :tag => 'span', :content => '(removed user)', :attributes => {:class => 'comment-info'}
  931 + assert_tag :tag => 'span', :content => '(removed user)', :attributes => {:class => 'comment-user-status icon-user-removed'}
932 932 end
933 933  
934 934 should 'show comment form opened on error' do
... ... @@ -1245,4 +1245,75 @@ class ContentViewerControllerTest &lt; Test::Unit::TestCase
1245 1245 assert_redirected_to :profile => @profile.identifier, :page => page.explode_path
1246 1246 end
1247 1247  
  1248 + should 'display reply to comment button if authenticated' do
  1249 + profile = create_user('testuser').person
  1250 + article = profile.articles.build(:name => 'test')
  1251 + article.save!
  1252 + comment = article.comments.build(:author => profile, :title => 'a comment', :body => 'lalala')
  1253 + comment.save!
  1254 + login_as 'testuser'
  1255 + get :view_page, :profile => 'testuser', :page => [ 'test' ]
  1256 + assert_tag :tag => 'a', :attributes => { :class => /comment-reply-link/ }
  1257 + end
  1258 +
  1259 + should 'display reply to comment button if not authenticated' do
  1260 + profile = create_user('testuser').person
  1261 + article = profile.articles.build(:name => 'test')
  1262 + article.save!
  1263 + comment = article.comments.build(:author => profile, :title => 'a comment', :body => 'lalala')
  1264 + comment.save!
  1265 + get :view_page, :profile => 'testuser', :page => [ 'test' ]
  1266 + assert_tag :tag => 'a', :attributes => { :class => /comment-reply-link/ }
  1267 + end
  1268 +
  1269 + should 'display replies if comment has replies' do
  1270 + profile = create_user('testuser').person
  1271 + article = profile.articles.build(:name => 'test')
  1272 + article.save!
  1273 + comment1 = article.comments.build(:author => profile, :title => 'a comment', :body => 'lalala')
  1274 + comment1.save!
  1275 + comment2 = article.comments.build(:author => profile, :title => 'a comment', :body => 'replying to lalala', :reply_of_id => comment1.id)
  1276 + comment2.save!
  1277 + get :view_page, :profile => 'testuser', :page => [ 'test' ]
  1278 + assert_tag :tag => 'ul', :attributes => { :class => 'comment-replies' }
  1279 + end
  1280 +
  1281 + should 'not display replies if comment does not have replies' do
  1282 + profile = create_user('testuser').person
  1283 + article = profile.articles.build(:name => 'test')
  1284 + article.save!
  1285 + comment = article.comments.build(:author => profile, :title => 'a comment', :body => 'lalala')
  1286 + comment.save!
  1287 + get :view_page, :profile => 'testuser', :page => [ 'test' ]
  1288 + assert_no_tag :tag => 'ul', :attributes => { :class => 'comment-replies' }
  1289 + end
  1290 +
  1291 + should 'show reply error' do
  1292 + profile = create_user('testuser').person
  1293 + article = profile.articles.build(:name => 'test')
  1294 + article.save!
  1295 + comment = article.comments.build(:author => profile, :title => 'root', :body => 'root')
  1296 + comment.save!
  1297 + login_as 'testuser'
  1298 + post :view_page, :profile => profile.identifier, :page => ['test'], :comment => { :title => '', :body => '', :reply_of_id => comment.id }, :confirm => 'true'
  1299 + assert_tag :tag => 'div', :attributes => { :class => /comment_reply/ }, :descendant => {:tag => 'div', :attributes => {:class => 'errorExplanation'} }
  1300 + assert_no_tag :tag => 'div', :attributes => { :id => 'page-comment-form' }, :descendant => {:tag => 'div', :attributes => {:class => 'errorExplanation'} }
  1301 + assert_tag :tag => 'div', :attributes => { :id => 'page-comment-form' }, :descendant => { :tag => 'div', :attributes => { :class => /post_comment_box closed/ } }
  1302 + end
  1303 +
  1304 + should 'show comment error' do
  1305 + profile = create_user('testuser').person
  1306 + article = profile.articles.build(:name => 'test')
  1307 + article.save!
  1308 + comment1 = article.comments.build(:author => profile, :title => 'root', :body => 'root')
  1309 + comment1.save!
  1310 + comment2 = article.comments.build(:author => profile, :title => 'root', :body => 'root', :reply_of_id => comment1.id)
  1311 + comment2.save!
  1312 + login_as 'testuser'
  1313 + post :view_page, :profile => profile.identifier, :page => ['test'], :comment => { :title => '', :body => '' }, :confirm => 'true'
  1314 + assert_no_tag :tag => 'div', :attributes => { :class => /comment_reply/ }, :descendant => {:tag => 'div', :attributes => {:class => 'errorExplanation'} }
  1315 + assert_tag :tag => 'div', :attributes => { :id => 'page-comment-form' }, :descendant => {:tag => 'div', :attributes => {:class => 'errorExplanation'} }
  1316 + assert_tag :tag => 'div', :attributes => { :id => 'page-comment-form' }, :descendant => { :tag => 'div', :attributes => { :class => /post_comment_box opened/ } }
  1317 + end
  1318 +
1248 1319 end
... ...
test/unit/comment_test.rb
... ... @@ -238,4 +238,83 @@ class CommentTest &lt; Test::Unit::TestCase
238 238 assert_equal owner, ta.target
239 239 end
240 240  
  241 + should "get children of a comment" do
  242 + c = fast_create(Comment)
  243 + c1 = fast_create(Comment, :reply_of_id => c.id)
  244 + c2 = fast_create(Comment)
  245 + c3 = fast_create(Comment, :reply_of_id => c.id)
  246 + assert_equal [c1,c3], c.children
  247 + end
  248 +
  249 + should "get parent of a comment" do
  250 + c = fast_create(Comment)
  251 + c1 = fast_create(Comment, :reply_of_id => c.id)
  252 + c2 = fast_create(Comment, :reply_of_id => c1.id)
  253 + c3 = fast_create(Comment, :reply_of_id => c.id)
  254 + c4 = fast_create(Comment)
  255 + assert_equal c, c1.reply_of
  256 + assert_equal c, c3.reply_of
  257 + assert_equal c1, c2.reply_of
  258 + assert_nil c4.reply_of
  259 + end
  260 +
  261 + should 'destroy replies when comment is removed' do
  262 + Comment.delete_all
  263 + owner = create_user('testuser').person
  264 + article = owner.articles.create!(:name => 'test', :body => '...')
  265 + c = article.comments.create!(:article => article, :name => 'foo', :title => 'bar', :body => 'my comment', :email => 'cracker@test.org')
  266 + c1 = article.comments.create!(:article => article, :name => 'foo', :title => 'bar', :body => 'my comment', :email => 'cracker@test.org', :reply_of_id => c.id)
  267 + c2 = article.comments.create!(:article => article, :name => 'foo', :title => 'bar', :body => 'my comment', :email => 'cracker@test.org')
  268 + c3 = article.comments.create!(:article => article, :name => 'foo', :title => 'bar', :body => 'my comment', :email => 'cracker@test.org', :reply_of_id => c.id)
  269 + assert_equal 4, Comment.count
  270 + c.destroy
  271 + assert_equal [c2], Comment.all
  272 + end
  273 +
  274 + should "get children if replies are not loaded" do
  275 + c = fast_create(Comment)
  276 + c1 = fast_create(Comment, :reply_of_id => c.id)
  277 + c2 = fast_create(Comment)
  278 + c3 = fast_create(Comment, :reply_of_id => c.id)
  279 + assert_nil c.instance_variable_get('@replies')
  280 + assert_equal [c1,c3], c.replies
  281 + end
  282 +
  283 + should "get replies if they are loaded" do
  284 + c = fast_create(Comment)
  285 + c1 = fast_create(Comment, :reply_of_id => c.id)
  286 + c2 = fast_create(Comment)
  287 + c3 = fast_create(Comment, :reply_of_id => c.id)
  288 + c.replies = [c2]
  289 + assert_not_nil c.instance_variable_get('@replies')
  290 + assert_equal [c2], c.replies
  291 + end
  292 +
  293 + should "set replies" do
  294 + c = fast_create(Comment)
  295 + c1 = fast_create(Comment, :reply_of_id => c.id)
  296 + c2 = fast_create(Comment)
  297 + c3 = fast_create(Comment, :reply_of_id => c.id)
  298 + c.replies = []
  299 + c.replies << c2
  300 + assert_equal [c2], c.instance_variable_get('@replies')
  301 + assert_equal [c2], c.replies
  302 + assert_equal [c1,c3], c.reload.children
  303 + end
  304 +
  305 + should "return comments as a thread" do
  306 + a = fast_create(Article)
  307 + c0 = fast_create(Comment, :article_id => a.id)
  308 + c1 = fast_create(Comment, :reply_of_id => c0.id, :article_id => a.id)
  309 + c2 = fast_create(Comment, :reply_of_id => c1.id, :article_id => a.id)
  310 + c3 = fast_create(Comment, :reply_of_id => c0.id, :article_id => a.id)
  311 + c4 = fast_create(Comment, :article_id => a.id)
  312 + result = a.comments.as_thread
  313 + assert_equal c0.id, result[0].id
  314 + assert_equal [c1.id, c3.id], result[0].replies.map(&:id)
  315 + assert_equal [c2.id], result[0].replies[0].replies.map(&:id)
  316 + assert_equal c4.id, result[1].id
  317 + assert result[1].replies.empty?
  318 + end
  319 +
241 320 end
... ...