decidim-comments 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -1
  3. data/app/assets/javascripts/decidim/comments/bundle.js +43 -44
  4. data/app/assets/javascripts/decidim/comments/bundle.js.map +1 -1
  5. data/app/commands/decidim/comments/vote_comment.rb +38 -0
  6. data/app/frontend/application/icon.component.jsx +9 -4
  7. data/app/frontend/application/icon.component.test.jsx +12 -2
  8. data/app/frontend/comments/add_comment_form.component.jsx +43 -11
  9. data/app/frontend/comments/add_comment_form.mutation.graphql +1 -4
  10. data/app/frontend/comments/comment.component.jsx +67 -7
  11. data/app/frontend/comments/comment.component.test.jsx +49 -1
  12. data/app/frontend/comments/comment_data.fragment.graphql +3 -0
  13. data/app/frontend/comments/comment_order_selector.component.jsx +51 -6
  14. data/app/frontend/comments/comment_order_selector.component.test.jsx +12 -1
  15. data/app/frontend/comments/comment_thread.component.jsx +11 -5
  16. data/app/frontend/comments/comment_thread.component.test.jsx +13 -3
  17. data/app/frontend/comments/comment_thread.fragment.graphql +1 -3
  18. data/app/frontend/comments/comments.component.jsx +37 -11
  19. data/app/frontend/comments/comments.component.test.jsx +44 -7
  20. data/app/frontend/comments/comments.query.graphql +2 -2
  21. data/app/frontend/comments/down_vote.fragment.graphql +6 -0
  22. data/app/frontend/comments/down_vote.mutation.graphql +7 -0
  23. data/app/frontend/comments/down_vote_button.component.jsx +84 -0
  24. data/app/frontend/comments/down_vote_button.component.test.jsx +48 -0
  25. data/app/frontend/comments/up_vote.fragment.graphql +6 -0
  26. data/app/frontend/comments/up_vote.mutation.graphql +7 -0
  27. data/app/frontend/comments/up_vote_button.component.jsx +84 -0
  28. data/app/frontend/comments/up_vote_button.component.test.jsx +48 -0
  29. data/app/frontend/comments/vote_button.component.jsx +19 -0
  30. data/app/frontend/comments/vote_button_component.test.jsx +38 -0
  31. data/app/frontend/support/generate_comments_data.js +7 -2
  32. data/app/helpers/decidim/comments/comments_helper.rb +1 -1
  33. data/app/models/decidim/comments/application_record.rb +9 -0
  34. data/app/models/decidim/comments/comment.rb +19 -4
  35. data/app/models/decidim/comments/comment_vote.rb +22 -0
  36. data/app/models/decidim/comments/seed.rb +27 -0
  37. data/app/queries/decidim/comments/comments_with_replies.rb +88 -0
  38. data/app/resolvers/decidim/comments/vote_comment_resolver.rb +20 -0
  39. data/app/types/decidim/comments/comment_mutation_type.rb +19 -0
  40. data/app/types/decidim/comments/comment_type.rb +38 -2
  41. data/config/locales/en.yml +1 -0
  42. data/db/migrate/20161130143508_create_comments.rb +4 -2
  43. data/db/migrate/20161219150806_create_comment_votes.rb +13 -0
  44. data/lib/decidim/comments/mutation_extensions.rb +9 -0
  45. data/lib/decidim/comments/query_extensions.rb +3 -5
  46. data/lib/decidim/comments/test/factories.rb +22 -0
  47. metadata +26 -49
@@ -0,0 +1,38 @@
1
+ /* eslint-disable no-unused-expressions */
2
+ import { shallow } from 'enzyme';
3
+
4
+ import VoteButton from './vote_button.component';
5
+ import Icon from '../application/icon.component';
6
+
7
+ import stubComponent from '../support/stub_component';
8
+
9
+ describe("<VoteButton />", () => {
10
+ const voteAction = sinon.spy();
11
+ stubComponent(Icon);
12
+
13
+ it("should render the number of votes passed as a prop", () => {
14
+ const wrapper = shallow(<VoteButton votes={10} buttonClassName="vote-button" iconName="vote-icon" voteAction={voteAction} />);
15
+ expect(wrapper.find('button')).to.include.text(10);
16
+ });
17
+
18
+ it("should render a button with the given buttonClassName", () => {
19
+ const wrapper = shallow(<VoteButton votes={10} buttonClassName="vote-button" iconName="vote-icon" voteAction={voteAction} />);
20
+ expect(wrapper.find('button.vote-button')).to.be.present();
21
+ });
22
+
23
+ it("should render a Icon component with the correct name prop", () => {
24
+ const wrapper = shallow(<VoteButton votes={10} buttonClassName="vote-button" iconName="vote-icon" voteAction={voteAction} />);
25
+ expect(wrapper.find(Icon)).to.have.prop("name").equal('vote-icon');
26
+ });
27
+
28
+ it("should call the voteAction prop on click", () => {
29
+ const wrapper = shallow(<VoteButton votes={10} buttonClassName="vote-button" iconName="vote-icon" voteAction={voteAction} />);
30
+ wrapper.find('button').simulate('click');
31
+ expect(voteAction).to.have.been.called;
32
+ });
33
+
34
+ it("should disable the button based on the disabled prop", () => {
35
+ const wrapper = shallow(<VoteButton votes={10} buttonClassName="vote-button" iconName="vote-icon" voteAction={voteAction} disabled />);
36
+ expect(wrapper.find('button')).to.be.disabled();
37
+ })
38
+ });
@@ -12,14 +12,19 @@ const generateCommentsData = (num = 1) => {
12
12
  commentsData.push({
13
13
  id: random.uuid(),
14
14
  body: random.words(),
15
- createdAt: date.past().toString(),
15
+ createdAt: date.past().toISOString(),
16
16
  author: {
17
17
  name: name.findName(),
18
18
  avatarUrl: image.imageUrl()
19
19
  },
20
+ hasReplies: false,
20
21
  replies: [],
21
22
  canHaveReplies: true,
22
- alignment: 0
23
+ alignment: 0,
24
+ upVotes: random.number(),
25
+ upVoted: false,
26
+ downVotes: random.number(),
27
+ downVoted: false
23
28
  })
24
29
  }
25
30
 
@@ -18,7 +18,7 @@ module Decidim
18
18
 
19
19
  react_comments_component(node_id, commentableType: commentable_type,
20
20
  commentableId: commentable_id,
21
- options: options.slice(:arguable),
21
+ options: options.slice(:arguable, :votable),
22
22
  locale: I18n.locale)
23
23
  end
24
24
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # Abstract class from which all models in this engine inherit.
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
9
+ end
@@ -13,10 +13,11 @@ module Decidim
13
13
  # |--R (depth 3)
14
14
  MAX_DEPTH = 3
15
15
 
16
- belongs_to :author, class_name: Decidim::User
17
- belongs_to :commentable, polymorphic: true
18
- has_many :replies, as: :commentable, class_name: Comment
19
-
16
+ belongs_to :author, foreign_key: "decidim_author_id", class_name: Decidim::User
17
+ belongs_to :commentable, foreign_key: "decidim_commentable_id", foreign_type: "decidim_commentable_type", polymorphic: true
18
+ has_many :replies, as: :commentable, foreign_key: "decidim_commentable_id", foreign_type: "decidim_commentable_type", class_name: Comment
19
+ has_many :up_votes, -> { where(weight: 1) }, foreign_key: "decidim_comment_id", class_name: CommentVote, dependent: :destroy
20
+ has_many :down_votes, -> { where(weight: -1) }, foreign_key: "decidim_comment_id", class_name: CommentVote, dependent: :destroy
20
21
  validates :author, :commentable, :body, presence: true
21
22
  validate :commentable_can_have_replies
22
23
  validates :depth, numericality: { greater_than_or_equal_to: 0 }
@@ -34,6 +35,20 @@ module Decidim
34
35
  depth < MAX_DEPTH
35
36
  end
36
37
 
38
+ # Public: Check if the user has upvoted the comment
39
+ #
40
+ # Returns a bool value to indicate if the condition is truthy or not
41
+ def up_voted_by?(user)
42
+ up_votes.any? { |vote| vote.author == user }
43
+ end
44
+
45
+ # Public: Check if the user has downvoted the comment
46
+ #
47
+ # Returns a bool value to indicate if the condition is truthy or not
48
+ def down_voted_by?(user)
49
+ down_votes.any? { |vote| vote.author == user }
50
+ end
51
+
37
52
  private
38
53
 
39
54
  # Private: Check if commentable can have replies and if not adds
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # A comment can include user votes. A user should be able to upVote, votes with
5
+ # weight 1 and downVote, votes with weight -1.
6
+ class CommentVote < ApplicationRecord
7
+ belongs_to :comment, foreign_key: "decidim_comment_id", class_name: Comment
8
+ belongs_to :author, foreign_key: "decidim_author_id", class_name: Decidim::User
9
+
10
+ validates :comment, uniqueness: { scope: :author }
11
+ validates :weight, inclusion: { in: [-1, 1] }
12
+ validate :author_and_comment_same_organization
13
+
14
+ private
15
+
16
+ # Private: check if the comment and the author have the same organization
17
+ def author_and_comment_same_organization
18
+ errors.add(:comment, :invalid) unless author.organization == comment.organization
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # A comment can belong to many Commentable models. This class is responsible
5
+ # to Seed those models in order to be able to use them in the development
6
+ # app.
7
+ class Seed
8
+ # Public: adds a random amount of comments for a given resource.
9
+ #
10
+ # resource - the resource to add the coments to.
11
+ #
12
+ # Returns nothing.
13
+ def self.comments_for(resource)
14
+ organization = resource.organization
15
+
16
+ rand(1..5).times do
17
+ random = rand(Decidim::User.count)
18
+ Comment.create(
19
+ commentable: resource,
20
+ body: ::Faker::Lorem.sentence,
21
+ author: Decidim::User.where(organization: organization).offset(random).first
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # A class used to find comments for a commentable resource
5
+ class CommentsWithReplies < Rectify::Query
6
+ attr_reader :commentable
7
+
8
+ # Syntactic sugar to initialize the class and return the queried objects.
9
+ #
10
+ # commentable - a resource that can have comments
11
+ # options - The Hash options is used to refine the selection ( default: {}):
12
+ # :order_by - The string order_by to sort by ( optional )
13
+ def self.for(commentable, options = {})
14
+ new(commentable, options).query
15
+ end
16
+
17
+ # Initializes the class.
18
+ #
19
+ # commentable = a resource that can have comments
20
+ # options - The Hash options is used to refine the selection ( default: {}):
21
+ # :order_by - The string order_by to sort by ( optional )
22
+ def initialize(commentable, options = {})
23
+ options[:order_by] ||= "older"
24
+ @commentable = commentable
25
+ @options = options
26
+ end
27
+
28
+ # Finds the Comments for a resource that can have comments and eager
29
+ # loads comments replies. It uses Comment's MAX_DEPTH to load a maximum
30
+ # level of nested replies.
31
+ def query
32
+ scope = Comment
33
+ .where(commentable: commentable)
34
+ .includes(:author, :up_votes, :down_votes)
35
+ .includes(
36
+ replies: [:author, :up_votes, :down_votes,
37
+ replies: [:author, :up_votes, :down_votes,
38
+ replies: [:author, :up_votes, :down_votes]]]
39
+ )
40
+
41
+ scope = case @options[:order_by]
42
+ when "older"
43
+ order_by_older(scope)
44
+ when "recent"
45
+ order_by_recent(scope)
46
+ when "best_rated"
47
+ order_by_best_rated(scope)
48
+ when "most_discussed"
49
+ order_by_most_discussed(scope)
50
+ else
51
+ order_by_older(scope)
52
+ end
53
+
54
+ scope
55
+ end
56
+
57
+ private
58
+
59
+ def order_by_older(scope)
60
+ scope.order(created_at: :asc)
61
+ end
62
+
63
+ def order_by_recent(scope)
64
+ scope.order(created_at: :desc)
65
+ end
66
+
67
+ def order_by_best_rated(scope)
68
+ scope.sort_by do |comment|
69
+ comment.up_votes.size - comment.down_votes.size
70
+ end.reverse
71
+ end
72
+
73
+ def order_by_most_discussed(scope)
74
+ scope.sort_by do |comment|
75
+ count_replies(comment)
76
+ end.reverse
77
+ end
78
+
79
+ def count_replies(comment)
80
+ if comment.replies.size.positive?
81
+ comment.replies.size + comment.replies.map { |reply| count_replies(reply) }.sum
82
+ else
83
+ 0
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ # A GraphQL resolver to handle `upVote` and `downVote` mutations
5
+ # It creates a vote for a comment by the current user.
6
+ class VoteCommentResolver
7
+ def initialize(options = { weight: 1 })
8
+ @weight = options[:weight]
9
+ end
10
+
11
+ def call(obj, _args, ctx)
12
+ Decidim::Comments::VoteComment.call(obj, ctx[:current_user], weight: @weight) do
13
+ on(:ok) do |comment|
14
+ return comment
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Comments
4
+ CommentMutationType = GraphQL::ObjectType.define do
5
+ name "CommentMutation"
6
+ description "A comment which includes its available mutations"
7
+
8
+ field :id, !types.ID, "The Comment's unique ID"
9
+
10
+ field :upVote, Decidim::Comments::CommentType do
11
+ resolve VoteCommentResolver.new(weight: 1)
12
+ end
13
+
14
+ field :downVote, Decidim::Comments::CommentType do
15
+ resolve VoteCommentResolver.new(weight: -1)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -11,18 +11,54 @@ module Decidim
11
11
  field :body, !types.String, "The comment message"
12
12
 
13
13
  field :createdAt, !types.String, "The creation date of the comment" do
14
- property :created_at
14
+ resolve lambda { |obj, _args, _ctx|
15
+ obj.created_at.iso8601
16
+ }
15
17
  end
16
18
 
17
19
  field :author, !AuthorType, "The comment's author"
18
20
 
19
- field :replies, !types[CommentType], "The comment's replies"
21
+ field :replies, !types[CommentType], "The comment's replies" do
22
+ resolve lambda { |obj, _args, _ctx|
23
+ obj.replies.sort_by(&:created_at)
24
+ }
25
+ end
26
+
27
+ field :hasReplies, !types.Boolean, "Check if the comment has replies" do
28
+ resolve lambda { |obj, _args, _ctx|
29
+ obj.replies.size.positive?
30
+ }
31
+ end
20
32
 
21
33
  field :canHaveReplies, !types.Boolean, "Define if a comment can or not have replies" do
22
34
  property :can_have_replies?
23
35
  end
24
36
 
25
37
  field :alignment, types.Int, "The comment's alignment. Can be 0 (neutral), 1 (in favor) or -1 (against)'"
38
+
39
+ field :upVotes, !types.Int, "The number of comment's upVotes" do
40
+ resolve lambda { |obj, _args, _ctx|
41
+ obj.up_votes.size
42
+ }
43
+ end
44
+
45
+ field :upVoted, !types.Boolean, "Check if the current user has upvoted the comment" do
46
+ resolve lambda { |obj, _args, ctx|
47
+ obj.up_voted_by?(ctx[:current_user])
48
+ }
49
+ end
50
+
51
+ field :downVotes, !types.Int, "The number of comment's downVotes" do
52
+ resolve lambda { |obj, _args, _ctx|
53
+ obj.down_votes.size
54
+ }
55
+ end
56
+
57
+ field :downVoted, !types.Boolean, "Check if the current user has downvoted the comment" do
58
+ resolve lambda { |obj, _args, ctx|
59
+ obj.down_voted_by?(ctx[:current_user])
60
+ }
61
+ end
26
62
  end
27
63
  end
28
64
  end
@@ -31,6 +31,7 @@ en:
31
31
  comment_thread:
32
32
  title: Conversation with %{authorName}
33
33
  comments:
34
+ loading: Loading comments ...
34
35
  title: "%{count} comments"
35
36
  featured_comment:
36
37
  title: Featured comment
@@ -2,10 +2,12 @@ class CreateComments < ActiveRecord::Migration[5.0]
2
2
  def change
3
3
  create_table :decidim_comments_comments do |t|
4
4
  t.text :body, null: false
5
- t.references :commentable, null: false, polymorphic: true, index: { name: "decidim_comments_comment_commentable" }
6
- t.references :author, null: false, index: { name: "decidim_comments_comment_author" }
5
+ t.references :decidim_commentable, null: false, polymorphic: true, index: { name: "decidim_comments_comment_commentable" }
6
+ t.references :decidim_author, null: false, index: { name: "decidim_comments_comment_author" }
7
7
 
8
8
  t.timestamps
9
9
  end
10
+
11
+ add_index :decidim_comments_comments, :created_at
10
12
  end
11
13
  end
@@ -0,0 +1,13 @@
1
+ class CreateCommentVotes < ActiveRecord::Migration[5.0]
2
+ def change
3
+ create_table :decidim_comments_comment_votes do |t|
4
+ t.integer :weight, null: false
5
+ t.references :decidim_comment, null: false, index: { name: "decidim_comments_comment_vote_comment" }
6
+ t.references :decidim_author, null: false, index: { name: "decidim_comments_comment_vote_author" }
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :decidim_comments_comment_votes, [:decidim_comment_id, :decidim_author_id], unique: true, name: "decidim_comments_comment_vote_comment_author_unique"
12
+ end
13
+ end
@@ -29,6 +29,15 @@ module Decidim
29
29
  end
30
30
  }
31
31
  end
32
+
33
+ field :comment, Decidim::Comments::CommentMutationType do
34
+ description "A comment"
35
+ argument :id, !types.ID, "The comment's id"
36
+
37
+ resolve lambda { |_obj, args, _ctx|
38
+ Comment.find(args["id"])
39
+ }
40
+ end
32
41
  end
33
42
  end
34
43
  end
@@ -17,13 +17,11 @@ module Decidim
17
17
 
18
18
  argument :commentableId, !types.String, "The commentable's ID"
19
19
  argument :commentableType, !types.String, "The commentable's class name. i.e. `Decidim::ParticipatoryProcess`"
20
+ argument :orderBy, types.String, "Order the comments"
20
21
 
21
22
  resolve lambda { |_obj, args, _ctx|
22
- Comment
23
- .where(commentable_id: args[:commentableId])
24
- .where(commentable_type: args[:commentableType])
25
- .order(created_at: :asc)
26
- .all
23
+ commentable = args[:commentableType].constantize.find(args[:commentableId])
24
+ CommentsWithReplies.for(commentable, order_by: args[:orderBy])
27
25
  }
28
26
  end
29
27
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ FactoryGirl.define do
3
+ factory :comment, class: Decidim::Comments::Comment do
4
+ author { build(:user, organization: commentable.organization) }
5
+ commentable { build(:participatory_process) }
6
+ body { Faker::Lorem.paragraph }
7
+ end
8
+
9
+ factory :comment_vote, class: Decidim::Comments::CommentVote do
10
+ comment { build(:comment) }
11
+ author { build(:user, organization: comment.organization) }
12
+ weight { [-1, 1].sample }
13
+
14
+ trait :up_vote do
15
+ weight 1
16
+ end
17
+
18
+ trait :down_vote do
19
+ weight(-1)
20
+ end
21
+ end
22
+ end