decidim-comments 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Rakefile +1 -1
- data/app/assets/javascripts/decidim/comments/bundle.js +43 -44
- data/app/assets/javascripts/decidim/comments/bundle.js.map +1 -1
- data/app/commands/decidim/comments/vote_comment.rb +38 -0
- data/app/frontend/application/icon.component.jsx +9 -4
- data/app/frontend/application/icon.component.test.jsx +12 -2
- data/app/frontend/comments/add_comment_form.component.jsx +43 -11
- data/app/frontend/comments/add_comment_form.mutation.graphql +1 -4
- data/app/frontend/comments/comment.component.jsx +67 -7
- data/app/frontend/comments/comment.component.test.jsx +49 -1
- data/app/frontend/comments/comment_data.fragment.graphql +3 -0
- data/app/frontend/comments/comment_order_selector.component.jsx +51 -6
- data/app/frontend/comments/comment_order_selector.component.test.jsx +12 -1
- data/app/frontend/comments/comment_thread.component.jsx +11 -5
- data/app/frontend/comments/comment_thread.component.test.jsx +13 -3
- data/app/frontend/comments/comment_thread.fragment.graphql +1 -3
- data/app/frontend/comments/comments.component.jsx +37 -11
- data/app/frontend/comments/comments.component.test.jsx +44 -7
- data/app/frontend/comments/comments.query.graphql +2 -2
- data/app/frontend/comments/down_vote.fragment.graphql +6 -0
- data/app/frontend/comments/down_vote.mutation.graphql +7 -0
- data/app/frontend/comments/down_vote_button.component.jsx +84 -0
- data/app/frontend/comments/down_vote_button.component.test.jsx +48 -0
- data/app/frontend/comments/up_vote.fragment.graphql +6 -0
- data/app/frontend/comments/up_vote.mutation.graphql +7 -0
- data/app/frontend/comments/up_vote_button.component.jsx +84 -0
- data/app/frontend/comments/up_vote_button.component.test.jsx +48 -0
- data/app/frontend/comments/vote_button.component.jsx +19 -0
- data/app/frontend/comments/vote_button_component.test.jsx +38 -0
- data/app/frontend/support/generate_comments_data.js +7 -2
- data/app/helpers/decidim/comments/comments_helper.rb +1 -1
- data/app/models/decidim/comments/application_record.rb +9 -0
- data/app/models/decidim/comments/comment.rb +19 -4
- data/app/models/decidim/comments/comment_vote.rb +22 -0
- data/app/models/decidim/comments/seed.rb +27 -0
- data/app/queries/decidim/comments/comments_with_replies.rb +88 -0
- data/app/resolvers/decidim/comments/vote_comment_resolver.rb +20 -0
- data/app/types/decidim/comments/comment_mutation_type.rb +19 -0
- data/app/types/decidim/comments/comment_type.rb +38 -2
- data/config/locales/en.yml +1 -0
- data/db/migrate/20161130143508_create_comments.rb +4 -2
- data/db/migrate/20161219150806_create_comment_votes.rb +13 -0
- data/lib/decidim/comments/mutation_extensions.rb +9 -0
- data/lib/decidim/comments/query_extensions.rb +3 -5
- data/lib/decidim/comments/test/factories.rb +22 -0
- 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().
|
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
|
|
@@ -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
|
-
|
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
|
data/config/locales/en.yml
CHANGED
@@ -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 :
|
6
|
-
t.references :
|
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
|
-
|
23
|
-
|
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
|