diff --git a/app/assets/fonts/icons.eot b/app/assets/fonts/icons.eot index 3046c50cc..ced98a9b4 100644 Binary files a/app/assets/fonts/icons.eot and b/app/assets/fonts/icons.eot differ diff --git a/app/assets/fonts/icons.svg b/app/assets/fonts/icons.svg index c107e740d..d92b9a44a 100644 --- a/app/assets/fonts/icons.svg +++ b/app/assets/fonts/icons.svg @@ -19,7 +19,6 @@ - @@ -30,12 +29,10 @@ - - @@ -45,4 +42,8 @@ + + + + diff --git a/app/assets/fonts/icons.ttf b/app/assets/fonts/icons.ttf index b933f1c3f..f3486cf96 100644 Binary files a/app/assets/fonts/icons.ttf and b/app/assets/fonts/icons.ttf differ diff --git a/app/assets/fonts/icons.woff b/app/assets/fonts/icons.woff index feb047bd0..9045aa563 100644 Binary files a/app/assets/fonts/icons.woff and b/app/assets/fonts/icons.woff differ diff --git a/app/assets/stylesheets/icons.scss b/app/assets/stylesheets/icons.scss index eda8b979d..46e24e4cf 100644 --- a/app/assets/stylesheets/icons.scss +++ b/app/assets/stylesheets/icons.scss @@ -38,116 +38,119 @@ } .icon-angle-down:before { - content: "a"; + content: "\61"; } .icon-angle-left:before { - content: "b"; + content: "\62"; } .icon-angle-right:before { - content: "c"; + content: "\63"; } .icon-angle-up:before { - content: "d"; + content: "\64"; } .icon-comments:before { - content: "e"; + content: "\65"; } .icon-twitter:before { - content: "f"; + content: "\66"; } .icon-calendar:before { - content: "g"; + content: "\67"; } .icon-debates:before { - content: "i"; + content: "\69"; } .icon-unlike:before { - content: "j"; + content: "\6a"; } .icon-like:before { - content: "k"; + content: "\6b"; } .icon-check:before { - content: "l"; + content: "\6c"; } .icon-edit:before { - content: "m"; -} -.icon-star:before { - content: "n"; + content: "\6d"; } .icon-user:before { - content: "o"; + content: "\6f"; } .icon-settings:before { - content: "q"; + content: "\71"; } .icon-stats:before { - content: "r"; + content: "\72"; } .icon-proposals:before { - content: "h"; + content: "\68"; } .icon-organizations:before { - content: "s"; + content: "\73"; } .icon-deleted:before { - content: "t"; + content: "\74"; } .icon-tag:before { - content: "u"; + content: "\75"; } .icon-eye:before { - content: "p"; + content: "\70"; } .icon-x:before { - content: "v"; + content: "\76"; } .icon-flag:before { - content: "w"; -} -.icon-notification:before { - content: "x"; + content: "\77"; } .icon-comment:before { - content: "y"; + content: "\79"; } .icon-reply:before { - content: "z"; + content: "\7a"; } .icon-facebook:before { - content: "A"; + content: "\41"; } .icon-google-plus:before { - content: "B"; -} -.icon-language:before { - content: "C"; + content: "\42"; } .icon-search:before { - content: "E"; + content: "\45"; } .icon-external:before { - content: "F"; + content: "\46"; } .icon-video:before { - content: "D"; + content: "\44"; } .icon-document:before { - content: "G"; + content: "\47"; } .icon-print:before { - content: "H"; + content: "\48"; } .icon-blog:before { - content: "J"; + content: "\4a"; } .icon-box:before { - content: "I"; + content: "\49"; } .icon-youtube:before { - content: "K"; + content: "\4b"; } .icon-letter:before { - content: "L"; -} \ No newline at end of file + content: "\4c"; +} +.icon-no-notification:before { + content: "\78"; +} +.icon-notification:before { + content: "\6e"; +} +.icon-circle:before { + content: "\43"; +} +.icon-circle-o:before { + content: "\4d"; +} diff --git a/app/assets/stylesheets/layout.scss b/app/assets/stylesheets/layout.scss index 62226e967..650621624 100644 --- a/app/assets/stylesheets/layout.scss +++ b/app/assets/stylesheets/layout.scss @@ -396,7 +396,6 @@ header { &:hover { background: none; color: white; - text-decoration: underline; transition: text-decoration 275ms; } @@ -420,6 +419,7 @@ header { &:hover, &:focus { background-color: #007095 !important; + text-decoration: underline; } } @@ -947,6 +947,77 @@ img.avatar, img.admin-avatar, img.moderator-avatar, img.initialjs-avatar { } } +.notifications { + position: relative; + + &:hover { + text-decoration: none; + } + + [class^="icon-"] { + font-size: $h4-font-size; + vertical-align: middle; + } + + .icon-circle { + color: #ecf00b; + font-size: $tiny-font-size; + position: absolute; + right: 4px; + top: -6px; + } +} + +.notifications-list:before { + background: $border; + content: ''; + height: 100%; + left: 28px; + position: absolute; + top: 0; + width: 2px; +} + +.notification { + display: block; + padding: $line-height/2 0 $line-height/2 $line-height*1.5; + position: relative; + + &:hover { + + a { + text-decoration: none; + } + + p:not(.time) { + color: $link; + } + + &:before { + content: "\43"; + } + } + + &:before { + background: white; + color: $brand; + content: "\4d"; + font-family: "icons" !important; + left: 6px; + position: absolute; + } + + p { + color: $text; + margin-bottom: 0; + } + + .time { + font-size: $small-font-size; + color: $text-medium; + } +} + // 09. Filters & search // - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1574,7 +1645,6 @@ table { font-family: $font-sans; font-size: $small-font-size; line-height: $line-height; - margin: rem-calc(10) $line-height/2 $line-height/4 0; a { color: $text-light; @@ -1595,15 +1665,12 @@ table { .comment-body { margin-left: rem-calc(42); - p { - font-size: $small-font-size; - } - .reply { background: white; border: 1px solid $border; - font-family: $font-sans; - font-size: rem-calc(12); + border-left: 0; + border-right: 0; + font-size: $small-font-size; margin: rem-calc(6) 0; padding: rem-calc(6); diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss index d9da9c580..6a61ec72c 100644 --- a/app/assets/stylesheets/variables.scss +++ b/app/assets/stylesheets/variables.scss @@ -39,6 +39,7 @@ $h6-font-size: rem-calc(13); $h6-line-height: rem-calc(17); $small-font-size: rem-calc(14); +$tiny-font-size: rem-calc(10); $line-height: rem-calc(24); // 02. Colors diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 841b32498..5150f97ec 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -108,4 +108,5 @@ class ApplicationController < ActionController::Base store_location_for(:user, request.path) end end + end diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 35406faf0..df928aa02 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -1,5 +1,5 @@ class CommentsController < ApplicationController - before_action :authenticate_user! + before_action :authenticate_user!, only: :create before_action :load_commentable, only: :create before_action :build_comment, only: :create @@ -9,11 +9,17 @@ class CommentsController < ApplicationController def create if @comment.save CommentNotifier.new(comment: @comment).process + add_notification @comment else render :new end end + def show + @comment = Comment.find(params[:id]) + set_comment_flags(@comment.subtree) + end + def vote @comment.vote_by(voter: current_user, vote: params[:value]) respond_with @comment @@ -62,4 +68,13 @@ class CommentsController < ApplicationController ["1", true].include?(comment_params[:as_moderator]) && can?(:comment_as_moderator, @commentable) end + def add_notification(comment) + if comment.reply? + notifiable = comment.parent + else + notifiable = comment.commentable + end + Notification.add(notifiable.author_id, notifiable) unless comment.author_id == notifiable.author_id + end + end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb new file mode 100644 index 000000000..a4ec31b50 --- /dev/null +++ b/app/controllers/notifications_controller.rb @@ -0,0 +1,26 @@ +class NotificationsController < ApplicationController + before_action :authenticate_user! + after_action :mark_as_read, only: :show + skip_authorization_check + + def index + @notifications = current_user.notifications.unread.recent.for_render + end + + def show + @notification = current_user.notifications.find(params[:id]) + redirect_to url_for(@notification.notifiable) + end + + def mark_all_as_read + current_user.notifications.each { |notification| notification.mark_as_read } + redirect_to notifications_path + end + + private + + def mark_as_read + @notification.mark_as_read + end + +end diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index 9ac29e4da..9d9d08075 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -13,8 +13,11 @@ module CommentsHelper end def child_comments_of(parent) - return [] unless @comment_tree - @comment_tree.children_of(parent) + if @comment_tree.present? + @comment_tree.ordered_children_of(parent) + else + parent.children + end end def user_level_class(comment) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb new file mode 100644 index 000000000..281163380 --- /dev/null +++ b/app/helpers/notifications_helper.rb @@ -0,0 +1,6 @@ +module NotificationsHelper + + def notification_action(notification) + notification.notifiable_type == "Comment" ? "replies_to" : "comments_on" + end +end diff --git a/app/models/abilities/everyone.rb b/app/models/abilities/everyone.rb index d6b2a6d57..122d5db2a 100644 --- a/app/models/abilities/everyone.rb +++ b/app/models/abilities/everyone.rb @@ -5,6 +5,7 @@ module Abilities def initialize(user) can :read, Debate can :read, Proposal + can :read, Comment can :read, Legislation can :read, User can [:search, :read], Annotation diff --git a/app/models/activity.rb b/app/models/activity.rb index 977204669..047ccb7dd 100644 --- a/app/models/activity.rb +++ b/app/models/activity.rb @@ -1,5 +1,4 @@ class Activity < ActiveRecord::Base - belongs_to :actionable, -> { with_hidden }, polymorphic: true belongs_to :user, -> { with_hidden } @@ -24,5 +23,4 @@ class Activity < ActiveRecord::Base def self.by(user) where(user: user) end - end diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 000000000..1cb500ccf --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,24 @@ +class Notification < ActiveRecord::Base + belongs_to :user, counter_cache: true + belongs_to :notifiable, polymorphic: true + + scope :unread, -> { all } + scope :recent, -> { order(id: :desc) } + scope :for_render, -> { includes(:notifiable) } + + def timestamp + notifiable.created_at + end + + def mark_as_read + self.destroy + end + + def self.add(user_id, notifiable) + if notification = Notification.find_by(user_id: user_id, notifiable: notifiable) + Notification.increment_counter(:counter, notification.id) + else + Notification.create!(user_id: user_id, notifiable: notifiable) + end + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index dd2bc5cf0..276598bf2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -22,6 +22,7 @@ class User < ActiveRecord::Base has_many :proposals, -> { with_hidden }, foreign_key: :author_id has_many :comments, -> { with_hidden } has_many :failed_census_calls + has_many :notifications validates :username, presence: true, if: :username_required? validates :username, uniqueness: true, if: :username_required? @@ -199,7 +200,7 @@ class User < ActiveRecord::Base def email_required? !erased? end - + def has_official_email? domain = Setting.value_for 'email_domain_for_officials' !email.blank? && ( (email.end_with? "@#{domain}") || (email.end_with? ".#{domain}") ) diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 9178f0b9b..e8c49636b 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -1,9 +1,9 @@ -<% cache [locale_and_user_status(comment), comment, commentable_cache_key(@commentable), comment.author, (@comment_flags[comment.id] if @comment_flags)] do %> +<% cache [locale_and_user_status(comment), comment, commentable_cache_key(comment.commentable), comment.author, (@comment_flags[comment.id] if @comment_flags)] do %>
<% if comment.hidden? || comment.user.hidden? %> - <% if child_comments_of(comment).size > 0 %> + <% if comment.children.size > 0 %>

<%= t("comments.comment.deleted") %>

@@ -49,7 +49,7 @@ <%= t("shared.collective") %> <% end %> - <% if comment.user_id == @commentable.author_id %> + <% if comment.user_id == comment.commentable.author_id %>  •  <%= t("comments.comment.author") %> @@ -63,16 +63,16 @@
+ <%= comment_author_class comment, comment.commentable.author_id %>"> <%= simple_format text_with_links comment.body %>
- - <%= render 'comments/votes', comment: comment %> - +
+ + <%= render 'comments/votes', comment: comment %> + -
- <%= t("comments.comment.responses", count: child_comments_of(comment).size) %> + <%= t("comments.comment.responses", count: comment.children.size) %> <% if user_signed_in? %>  |  @@ -81,11 +81,12 @@ <%= render 'comments/actions', comment: comment %> - <%= render 'comments/form', {commentable: @commentable, parent_id: comment.id, toggeable: true} %> + <%= render 'comments/form', {commentable: comment.commentable, parent_id: comment.id, toggeable: true} %> <% end %>
<% end %> +
<% child_comments_of(comment).each do |child| %> <%= render 'comments/comment', comment: child %> diff --git a/app/views/comments/show.html.erb b/app/views/comments/show.html.erb new file mode 100644 index 000000000..4fcbdead4 --- /dev/null +++ b/app/views/comments/show.html.erb @@ -0,0 +1,16 @@ +
+
+ <%= link_to @comment.commentable, class: "left back" do %> + + <%= t("comments.show.return_to_commentable") + @comment.commentable.title %> + <% end %> +
+
+ +
+
+
+ <%= render @comment %> +
+
+
\ No newline at end of file diff --git a/app/views/devise/menu/_login_items.html.erb b/app/views/devise/menu/_login_items.html.erb index b0998949f..7681619e1 100644 --- a/app/views/devise/menu/_login_items.html.erb +++ b/app/views/devise/menu/_login_items.html.erb @@ -1,5 +1,16 @@
    <% if user_signed_in? %> +
  • + <%= link_to notifications_path, class: "notifications" do %> + <% if current_user.notifications_count > 0 %> + + + + <% else %> + + <% end %> + <% end %> +
  • <%= link_to(t("layouts.header.my_activity_link"), user_path(current_user)) %>
  • diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb new file mode 100644 index 000000000..28def42f7 --- /dev/null +++ b/app/views/notifications/_notification.html.erb @@ -0,0 +1,9 @@ +
  • + <%= link_to notification do %> +

    + <%= t("notifications.index.#{notification_action(notification)}", count: notification.counter) %> + <%= notification.notifiable.is_a?(Comment) ? notification.notifiable.commentable.title : notification.notifiable.title %> +

    +

    <%= l notification.timestamp, format: :datetime %>

    + <% end %> +
  • \ No newline at end of file diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb new file mode 100644 index 000000000..cc9170e48 --- /dev/null +++ b/app/views/notifications/index.html.erb @@ -0,0 +1,18 @@ +
    +
    + <% if @notifications.empty? %> +
    + <%= t("notifications.index.empty_notifications") %> +
    + <% else %> +
    + <%= link_to t("notifications.index.mark_all_as_read"), + mark_all_as_read_notifications_path, method: :put %> +
    + +
      + <%= render @notifications %> +
    + <% end %> +
    +
    diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 561ad3347..6b815cd31 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -127,6 +127,8 @@ ignore_unused: - 'proposals.index.select_order' - 'proposals.index.orders.*' - 'proposals.index.search_form.*' + - 'notifications.index.comments_on*' + - 'notifications.index.replies_to*' - 'helpers.page_entries_info.*' # kaminari - 'views.pagination.*' # kaminari # - '{devise,kaminari,will_paginate}.*' diff --git a/config/locales/en.yml b/config/locales/en.yml index f8edcd12a..c40575d6b 100755 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -29,6 +29,10 @@ en: more_information: "More information" debates: "Debates" proposals: "Proposals" + new_notifications: + one: "You have a new notification" + other: "You have %{count} new notifications" + no_notifications: "You don't have new notifications" footer: description: "This portal uses the %{consul} which is %{open_source}. From Madrid out into the world." open_source: "open-source software" @@ -241,6 +245,8 @@ en: form: submit_button: "Save changes" comments: + show: + return_to_commentable: "Go back to " select_order: "Sort by" orders: most_voted: "Most voted" @@ -307,6 +313,16 @@ en: user_permission_votes: "Participate on final voting" user_permission_verify: "To perform all the actions verify your account." user_permission_verify_info: "* Only for users on Madrid City Census." + notifications: + index: + mark_all_as_read: "Mark all as read" + empty_notifications: "You don't have new notifications." + comments_on: + one: "Someone commented on" + other: "There are %{count} new comments on" + replies_to: + one: "Someone replied to your comment on" + other: "There are %{count} new replies to your comment on" simple_captcha: placeholder: "Enter the text from the image" label: "Enter the text from the image in the box below" diff --git a/config/locales/es.yml b/config/locales/es.yml index 8d2a2c2f7..37be2e03a 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -29,6 +29,10 @@ es: more_information: "Más información" debates: "Debates" proposals: "Propuestas" + new_notifications: + one: "Tienes una nueva notificación" + other: "Tienes %{count} notificaciones nuevas" + no_notifications: "No tienes notificaciones nuevas" footer: description: "Este portal usa la %{consul} que es %{open_source}. De Madrid, para el mundo entero." open_source: "software libre" @@ -241,6 +245,8 @@ es: form: submit_button: "Guardar cambios" comments: + show: + return_to_commentable: "Volver a " select_order: "Ordenar por" orders: most_voted: "Más votados" @@ -307,6 +313,16 @@ es: user_permission_votes: "Participar en las votaciones finales*" user_permission_verify: "Para poder realizar todas las acciones verifica tu cuenta." user_permission_verify_info: "* Sólo usuarios empadronados en el municipio de Madrid." + notifications: + index: + mark_all_as_read: "Marcar todas como leídas" + empty_notifications: "No tienes notificaciones nuevas." + comments_on: + one: "Hay un nuevo comentario en" + other: "Hay %{count} comentarios nuevos en" + replies_to: + one: "Hay una respuesta nueva a tu comentario en" + other: "Hay %{count} nuevas respuestas a tu comentario en" simple_captcha: placeholder: "Introduce el texto de la imagen" label: "Introduce el texto de la imagen en la siguiente caja" diff --git a/config/routes.rb b/config/routes.rb index 3472f1c4e..bfdecc59f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,7 +53,7 @@ Rails.application.routes.draw do end end - resources :comments, only: :create, shallow: true do + resources :comments, only: [:create, :show], shallow: true do member do post :vote put :flag @@ -74,6 +74,11 @@ Rails.application.routes.draw do resource :account, controller: "account", only: [:show, :update, :delete] do collection { get :erase } end + + resources :notifications, only: [:index, :show] do + collection { put :mark_all_as_read } + end + resource :verification, controller: "verification", only: [:show] scope module: :verification do diff --git a/db/migrate/20150928115005_create_notifications.rb b/db/migrate/20150928115005_create_notifications.rb new file mode 100644 index 000000000..42bbbf72e --- /dev/null +++ b/db/migrate/20150928115005_create_notifications.rb @@ -0,0 +1,9 @@ +class CreateNotifications < ActiveRecord::Migration + def change + create_table :notifications do |t| + t.belongs_to :user, index: true, foreign_key: true + t.belongs_to :activity, index: true, foreign_key: true + t.boolean :read, default: false + end + end +end diff --git a/db/migrate/20160105170113_merge_activities_and_notifications.rb b/db/migrate/20160105170113_merge_activities_and_notifications.rb new file mode 100644 index 000000000..fac5b213d --- /dev/null +++ b/db/migrate/20160105170113_merge_activities_and_notifications.rb @@ -0,0 +1,10 @@ +class MergeActivitiesAndNotifications < ActiveRecord::Migration + def change + change_table :notifications do |t| + t.remove :read + t.remove :activity_id + t.references :notifiable, polymorphic: true + end + end + +end diff --git a/db/migrate/20160108114750_add_counter_to_notifications.rb b/db/migrate/20160108114750_add_counter_to_notifications.rb new file mode 100644 index 000000000..7cf3c434c --- /dev/null +++ b/db/migrate/20160108114750_add_counter_to_notifications.rb @@ -0,0 +1,5 @@ +class AddCounterToNotifications < ActiveRecord::Migration + def change + add_column :notifications, :counter, :integer, default: 1 + end +end diff --git a/db/migrate/20160108133501_add_notifications_counter_cache_to_user.rb b/db/migrate/20160108133501_add_notifications_counter_cache_to_user.rb new file mode 100644 index 000000000..e106df6ae --- /dev/null +++ b/db/migrate/20160108133501_add_notifications_counter_cache_to_user.rb @@ -0,0 +1,5 @@ +class AddNotificationsCounterCacheToUser < ActiveRecord::Migration + def change + add_column :users, :notifications_count, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index ca180b297..162bd816e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20151215165824) do +ActiveRecord::Schema.define(version: 20160108133501) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -204,6 +204,15 @@ ActiveRecord::Schema.define(version: 20151215165824) do add_index "moderators", ["user_id"], name: "index_moderators_on_user_id", using: :btree + create_table "notifications", force: :cascade do |t| + t.integer "user_id" + t.integer "notifiable_id" + t.string "notifiable_type" + t.integer "counter", default: 1 + end + + add_index "notifications", ["user_id"], name: "index_notifications_on_user_id", using: :btree + create_table "organizations", force: :cascade do |t| t.integer "user_id" t.string "name", limit: 60 @@ -330,6 +339,7 @@ ActiveRecord::Schema.define(version: 20151215165824) do t.datetime "erased_at" t.boolean "public_activity", default: true t.boolean "newsletter", default: false + t.integer "notifications_count", default: 0 end add_index "users", ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree @@ -404,5 +414,6 @@ ActiveRecord::Schema.define(version: 20151215165824) do add_foreign_key "identities", "users" add_foreign_key "locks", "users" add_foreign_key "moderators", "users" + add_foreign_key "notifications", "users" add_foreign_key "organizations", "users" end diff --git a/lib/comment_tree.rb b/lib/comment_tree.rb index 8d0e072dd..f2eadb8ab 100644 --- a/lib/comment_tree.rb +++ b/lib/comment_tree.rb @@ -2,24 +2,34 @@ class CommentTree ROOT_COMMENTS_PER_PAGE = 10 - attr_accessor :root_comments, :comments + attr_accessor :root_comments, :comments, :commentable, :page, :order def initialize(commentable, page, order = 'confidence_score') - @root_comments = commentable.comments.roots.send("sort_by_#{order}").page(page).per(ROOT_COMMENTS_PER_PAGE).for_render - - root_descendants = @root_comments.each_with_object([]) do |root, col| - col.concat(Comment.descendants_of(root).send("sort_descendants_by_#{order}").for_render.to_a) - end + @commentable = commentable + @page = page + @order = order @comments = root_comments + root_descendants + end - @comments_by_parent_id = @comments.each_with_object({}) do |comment, col| - (col[comment.parent_id] ||= []) << comment + def root_comments + commentable.comments.roots.send("sort_by_#{order}").page(page).per(ROOT_COMMENTS_PER_PAGE).for_render + end + + def root_descendants + root_comments.each_with_object([]) do |root, array| + array.concat(Comment.descendants_of(root).send("sort_descendants_by_#{order}").for_render.to_a) end end - def children_of(parent) - @comments_by_parent_id[parent.id] || [] + def ordered_children_of(parent) + comments_by_parent_id[parent.id] || [] + end + + def comments_by_parent_id + comments.each_with_object({}) do |comment, array| + (array[comment.parent_id] ||= []) << comment + end end def comment_authors diff --git a/spec/factories.rb b/spec/factories.rb index 2c20000ca..6e910a214 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -290,4 +290,9 @@ FactoryGirl.define do sequence(:track_id) { |n| "#{n}" } end + factory :notification do + user + association :notifiable, factory: :proposal + end + end diff --git a/spec/features/comments/debates_spec.rb b/spec/features/comments/debates_spec.rb index 5adfa218c..7d807a6c0 100644 --- a/spec/features/comments/debates_spec.rb +++ b/spec/features/comments/debates_spec.rb @@ -20,6 +20,21 @@ feature 'Commenting debates' do end end + scenario 'Show' do + parent_comment = create(:comment, commentable: debate) + first_child = create(:comment, commentable: debate, parent: parent_comment) + second_child = create(:comment, commentable: debate, parent: parent_comment) + + visit comment_path(parent_comment) + + expect(page).to have_css(".comment", count: 3) + expect(page).to have_content parent_comment.body + expect(page).to have_content first_child.body + expect(page).to have_content second_child.body + + expect(page).to have_link "Go back to #{debate.title}", debate_path(debate) + end + scenario 'Comment order' do c1 = create(:comment, :with_confidence_score, commentable: debate, cached_votes_up: 100, cached_votes_total: 120, created_at: Time.now - 2) c2 = create(:comment, :with_confidence_score, commentable: debate, cached_votes_up: 10, cached_votes_total: 12, created_at: Time.now - 1) @@ -254,7 +269,7 @@ feature 'Commenting debates' do fill_in "comment-body-debate_#{debate.id}", with: 'Testing submit button!' click_button 'Publish comment' - + # The button's text should now be "..." # This should be checked before the Ajax request is finished expect(page).to_not have_button 'Publish comment' diff --git a/spec/features/comments/proposals_spec.rb b/spec/features/comments/proposals_spec.rb index 1c277cf4d..d919f0b51 100644 --- a/spec/features/comments/proposals_spec.rb +++ b/spec/features/comments/proposals_spec.rb @@ -20,6 +20,21 @@ feature 'Commenting proposals' do end end + scenario 'Show' do + parent_comment = create(:comment, commentable: proposal) + first_child = create(:comment, commentable: proposal, parent: parent_comment) + second_child = create(:comment, commentable: proposal, parent: parent_comment) + + visit comment_path(parent_comment) + + expect(page).to have_css(".comment", count: 3) + expect(page).to have_content parent_comment.body + expect(page).to have_content first_child.body + expect(page).to have_content second_child.body + + expect(page).to have_link "Go back to #{proposal.title}", proposal_path(proposal) + end + scenario 'Comment order' do c1 = create(:comment, :with_confidence_score, commentable: proposal, cached_votes_up: 100, cached_votes_total: 120, created_at: Time.now - 2) c2 = create(:comment, :with_confidence_score, commentable: proposal, cached_votes_up: 10, cached_votes_total: 12, created_at: Time.now - 1) diff --git a/spec/features/notifications_spec.rb b/spec/features/notifications_spec.rb new file mode 100644 index 000000000..1ffc6a854 --- /dev/null +++ b/spec/features/notifications_spec.rb @@ -0,0 +1,192 @@ +require 'rails_helper' + +feature "Notifications" do + let(:author) { create :user } + let(:user) { create :user } + let(:debate) { create :debate, author: author } + let(:proposal) { create :proposal, author: author } + + scenario "User commented on my debate", :js do + login_as user + visit debate_path debate + + fill_in "comment-body-debate_#{debate.id}", with: "I commented on your debate" + click_button "Publish comment" + within "#comments" do + expect(page).to have_content "I commented on your debate" + end + + logout + login_as author + visit root_path + + find(".icon-notification").click + + expect(page).to have_css ".notification", count: 1 + + expect(page).to have_content "Someone commented on" + expect(page).to have_xpath "//a[@href='#{notification_path(Notification.last)}']" + end + + scenario "Multiple comments on my proposal", :js do + login_as user + visit proposal_path proposal + + fill_in "comment-body-proposal_#{proposal.id}", with: "I agree" + click_button "Publish comment" + within "#comments" do + expect(page).to have_content "I agree" + end + + logout + login_as create(:user) + visit proposal_path proposal + + fill_in "comment-body-proposal_#{proposal.id}", with: "I disagree" + click_button "Publish comment" + within "#comments" do + expect(page).to have_content "I disagree" + end + + logout + login_as author + visit root_path + + find(".icon-notification").click + + expect(page).to have_css ".notification", count: 1 + + expect(page).to have_content "There are 2 new comments on" + expect(page).to have_xpath "//a[@href='#{notification_path(Notification.last)}']" + end + + scenario "User replied to my comment", :js do + comment = create :comment, commentable: debate, user: author + login_as user + visit debate_path debate + + click_link "Reply" + within "#js-comment-form-comment_#{comment.id}" do + fill_in "comment-body-comment_#{comment.id}", with: "I replied to your comment" + click_button "Publish reply" + end + + within "#comment_#{comment.id}" do + expect(page).to have_content "I replied to your comment" + end + + logout + login_as author + visit root_path + + find(".icon-notification").click + + expect(page).to have_css ".notification", count: 1 + expect(page).to have_content "Someone replied to your comment on" + expect(page).to have_xpath "//a[@href='#{notification_path(Notification.last)}']" + end + + scenario "Multiple replies to my comment", :js do + comment = create :comment, commentable: debate, user: author + 3.times do |n| + login_as create(:user) + visit debate_path debate + + within("#comment_#{comment.id}_reply") { click_link "Reply" } + within "#js-comment-form-comment_#{comment.id}" do + fill_in "comment-body-comment_#{comment.id}", with: "Reply number #{n}" + click_button "Publish reply" + end + + within "#comment_#{comment.id}" do + expect(page).to have_content "Reply number #{n}" + end + logout + end + + login_as author + visit root_path + + find(".icon-notification").click + + expect(page).to have_css ".notification", count: 1 + expect(page).to have_content "There are 3 new replies to your comment on" + expect(page).to have_xpath "//a[@href='#{notification_path(Notification.last)}']" + end + + scenario "Author commented on his own debate", :js do + login_as author + visit debate_path debate + + fill_in "comment-body-debate_#{debate.id}", with: "I commented on my own debate" + click_button "Publish comment" + within "#comments" do + expect(page).to have_content "I commented on my own debate" + end + + find(".icon-no-notification").click + expect(page).to have_css ".notification", count: 0 + end + + scenario "Author replied to his own comment", :js do + comment = create :comment, commentable: debate, user: author + login_as author + visit debate_path debate + + click_link "Reply" + within "#js-comment-form-comment_#{comment.id}" do + fill_in "comment-body-comment_#{comment.id}", with: "I replied to my own comment" + click_button "Publish reply" + end + + within "#comment_#{comment.id}" do + expect(page).to have_content "I replied to my own comment" + end + + find(".icon-no-notification") + + visit notifications_path + expect(page).to have_css ".notification", count: 0 + end + + context "mark as read" do + + scenario "mark a single notification as read" do + user = create :user + notification = create :notification, user: user + + login_as user + visit notifications_path + + expect(page).to have_css ".notification", count: 1 + + first(".notification a").click + visit notifications_path + + expect(page).to have_css ".notification", count: 0 + end + + scenario "mark all notifications as read" do + user = create :user + 2.times { create :notification, user: user } + + login_as user + visit notifications_path + + expect(page).to have_css ".notification", count: 2 + click_link "Mark all as read" + + expect(page).to have_css ".notification", count: 0 + expect(current_path).to eq(notifications_path) + end + + end + + scenario "no notifications" do + login_as user + visit notifications_path + + expect(page).to have_content "You don't have new notifications" + end + +end diff --git a/spec/helpers/notifications_helper_spec.rb b/spec/helpers/notifications_helper_spec.rb new file mode 100644 index 000000000..ccb3b3a9d --- /dev/null +++ b/spec/helpers/notifications_helper_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +describe NotificationsHelper do + + describe "#notification_action" do + let(:debate) { create :debate } + let(:debate_comment) { create :comment, commentable: debate } + + context "when action was comment on a debate" do + it "returns correct text when someone comments on your debate" do + notification = create :notification, notifiable: debate + expect(notification_action(notification)).to eq "comments_on" + end + end + + context "when action was comment on a debate" do + it "returns correct text when someone replies to your comment" do + notification = create :notification, notifiable: debate_comment + expect(notification_action(notification)).to eq "replies_to" + end + end + end + + +end diff --git a/spec/models/abilities/everyone_spec.rb b/spec/models/abilities/everyone_spec.rb index 4c532b7a4..dacac25e4 100644 --- a/spec/models/abilities/everyone_spec.rb +++ b/spec/models/abilities/everyone_spec.rb @@ -21,4 +21,6 @@ describe "Abilities::Everyone" do it { should_not be_able_to(:vote, Proposal) } it { should_not be_able_to(:flag, Proposal) } it { should_not be_able_to(:unflag, Proposal) } + + it { should be_able_to(:show, Comment) } end diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb index 11a02400a..73b70fb42 100644 --- a/spec/models/comment_spec.rb +++ b/spec/models/comment_spec.rb @@ -128,4 +128,5 @@ describe Comment do expect(Comment.not_as_admin_or_moderator.first).to eq(comment1) end end + end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb new file mode 100644 index 000000000..361211a51 --- /dev/null +++ b/spec/models/notification_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +describe Notification do + + describe "#unread (scope)" do + it "returns only unread notifications" do + 2.times { create :notification } + expect(Notification.unread.size).to be 2 + end + end + + describe "#recent (scope)" do + it "returns notifications sorted by id descendant" do + old_notification = create :notification + new_notification = create :notification + + sorted_notifications = Notification.recent + expect(sorted_notifications.size).to be 2 + expect(sorted_notifications.first).to eq new_notification + expect(sorted_notifications.last).to eq old_notification + end + end + + describe "#for_render (scope)" do + it "returns notifications including notifiable and user" do + expect(Notification).to receive(:includes).with(:notifiable).exactly(:once) + Notification.for_render + end + end + + describe "#timestamp" do + it "returns the timestamp of the trackable object" do + comment = create :comment + notification = create :notification, notifiable: comment + + expect(notification.timestamp).to eq comment.created_at + end + end + + describe "#mark_as_read" do + it "destroys notification" do + notification = create :notification + expect(Notification.unread.size).to eq 1 + + notification.mark_as_read + expect(Notification.unread.size).to eq 0 + end + end + +end