vote_fu 0.0.11 → 2.0.1
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 +7 -0
- data/CHANGELOG.md +84 -0
- data/README.md +265 -0
- data/app/assets/stylesheets/vote_fu/votes.css +391 -0
- data/app/channels/vote_fu/application_cable/channel.rb +8 -0
- data/app/channels/vote_fu/application_cable/connection.rb +39 -0
- data/app/channels/vote_fu/votes_channel.rb +99 -0
- data/app/components/vote_fu/like_button_component.rb +136 -0
- data/app/components/vote_fu/reaction_bar_component.rb +208 -0
- data/app/components/vote_fu/star_rating_component.rb +199 -0
- data/app/components/vote_fu/vote_widget_component.rb +181 -0
- data/app/controllers/vote_fu/application_controller.rb +49 -0
- data/app/controllers/vote_fu/votes_controller.rb +223 -0
- data/app/helpers/vote_fu/votes_helper.rb +176 -0
- data/app/javascript/vote_fu/channels/consumer.js +3 -0
- data/app/javascript/vote_fu/channels/index.js +2 -0
- data/app/javascript/vote_fu/channels/votes_channel.js +93 -0
- data/app/javascript/vote_fu/controllers/application.js +9 -0
- data/app/javascript/vote_fu/controllers/index.js +9 -0
- data/app/javascript/vote_fu/controllers/vote_fu_controller.js +148 -0
- data/app/javascript/vote_fu/controllers/vote_fu_reactions_controller.js +92 -0
- data/app/javascript/vote_fu/controllers/vote_fu_stars_controller.js +77 -0
- data/app/models/vote_fu/application_record.rb +7 -0
- data/app/models/vote_fu/vote.rb +90 -0
- data/app/views/vote_fu/votes/_count.html.erb +29 -0
- data/app/views/vote_fu/votes/_downvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_error.html.erb +9 -0
- data/app/views/vote_fu/votes/_like_button.html.erb +67 -0
- data/app/views/vote_fu/votes/_upvote_button.html.erb +40 -0
- data/app/views/vote_fu/votes/_widget.html.erb +85 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +9 -0
- data/lib/generators/vote_fu/install/install_generator.rb +56 -0
- data/lib/generators/vote_fu/install/templates/initializer.rb +42 -0
- data/lib/generators/vote_fu/install/templates/migration.rb.erb +41 -0
- data/lib/generators/vote_fu/migration/migration_generator.rb +29 -0
- data/lib/generators/vote_fu/migration/templates/create_vote_fu_votes.rb.erb +40 -0
- data/lib/vote_fu/algorithms/hacker_news.rb +54 -0
- data/lib/vote_fu/algorithms/reddit_hot.rb +55 -0
- data/lib/vote_fu/algorithms/wilson_score.rb +69 -0
- data/lib/vote_fu/concerns/karmatic.rb +320 -0
- data/lib/vote_fu/concerns/voteable.rb +291 -0
- data/lib/vote_fu/concerns/voter.rb +275 -0
- data/lib/vote_fu/configuration.rb +53 -0
- data/lib/vote_fu/engine.rb +54 -0
- data/lib/vote_fu/errors.rb +34 -0
- data/lib/vote_fu/version.rb +5 -0
- data/lib/vote_fu.rb +22 -9
- metadata +217 -60
- data/CHANGELOG.markdown +0 -31
- data/README.markdown +0 -220
- data/examples/routes.rb +0 -7
- data/examples/users_controller.rb +0 -76
- data/examples/voteable.html.erb +0 -8
- data/examples/voteable.rb +0 -10
- data/examples/voteables_controller.rb +0 -117
- data/examples/votes/_voteable_vote.html.erb +0 -23
- data/examples/votes/create.rjs +0 -1
- data/examples/votes_controller.rb +0 -110
- data/generators/vote_fu/templates/migration.rb +0 -21
- data/generators/vote_fu/vote_fu_generator.rb +0 -8
- data/init.rb +0 -1
- data/lib/acts_as_voteable.rb +0 -114
- data/lib/acts_as_voter.rb +0 -75
- data/lib/controllers/votes_controller.rb +0 -96
- data/lib/has_karma.rb +0 -68
- data/lib/models/vote.rb +0 -17
- data/rails/init.rb +0 -10
- data/test/vote_fu_test.rb +0 -8
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module VoteFu
|
|
6
|
+
module Concerns
|
|
7
|
+
module Voteable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
# Configure a model to receive votes
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# class Post < ApplicationRecord
|
|
15
|
+
# acts_as_voteable
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example With options
|
|
19
|
+
# class Post < ApplicationRecord
|
|
20
|
+
# acts_as_voteable counter_cache: true, broadcasts: true
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @param options [Hash] Configuration options
|
|
24
|
+
# @option options [Boolean] :counter_cache (true) Maintain vote count columns
|
|
25
|
+
# @option options [Boolean] :broadcasts (true) Broadcast changes via Turbo
|
|
26
|
+
# @option options [Array<Symbol>] :scopes (nil) Allowed voting scopes
|
|
27
|
+
def acts_as_voteable(**options)
|
|
28
|
+
class_attribute :vote_fu_voteable_options, default: {
|
|
29
|
+
counter_cache: VoteFu.configuration.counter_cache,
|
|
30
|
+
broadcasts: VoteFu.configuration.turbo_broadcasts,
|
|
31
|
+
scopes: nil
|
|
32
|
+
}.merge(options)
|
|
33
|
+
|
|
34
|
+
has_many :received_votes,
|
|
35
|
+
class_name: "VoteFu::Vote",
|
|
36
|
+
as: :voteable,
|
|
37
|
+
dependent: :destroy,
|
|
38
|
+
inverse_of: :voteable
|
|
39
|
+
|
|
40
|
+
include VoteFu::Concerns::Voteable::InstanceMethods
|
|
41
|
+
extend VoteFu::Concerns::Voteable::ClassMethods
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Alternative DSL: declare which models can vote on this one
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# class Post < ApplicationRecord
|
|
48
|
+
# voteable_by :users, :admins
|
|
49
|
+
# end
|
|
50
|
+
def voteable_by(*voter_classes, **options)
|
|
51
|
+
acts_as_voteable(**options)
|
|
52
|
+
|
|
53
|
+
class_attribute :vote_fu_allowed_voters, default: voter_classes.map(&:to_s).map(&:classify)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
module ClassMethods
|
|
58
|
+
# Order by total vote value (sum of all vote values)
|
|
59
|
+
def by_votes(direction = :desc)
|
|
60
|
+
left_joins(:received_votes)
|
|
61
|
+
.group(:id)
|
|
62
|
+
.order(Arel.sql("COALESCE(SUM(vote_fu_votes.value), 0) #{direction.to_s.upcase}"))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Order by vote count
|
|
66
|
+
def by_vote_count(direction = :desc)
|
|
67
|
+
left_joins(:received_votes)
|
|
68
|
+
.group(:id)
|
|
69
|
+
.order(Arel.sql("COUNT(vote_fu_votes.id) #{direction.to_s.upcase}"))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Items with positive net votes
|
|
73
|
+
def with_positive_score
|
|
74
|
+
left_joins(:received_votes)
|
|
75
|
+
.group(:id)
|
|
76
|
+
.having("COALESCE(SUM(vote_fu_votes.value), 0) > 0")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Items with any votes
|
|
80
|
+
def with_votes
|
|
81
|
+
joins(:received_votes).distinct
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Items without any votes
|
|
85
|
+
def without_votes
|
|
86
|
+
left_joins(:received_votes)
|
|
87
|
+
.where(vote_fu_votes: { id: nil })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Trending items (most votes in time period)
|
|
91
|
+
def trending(since: 24.hours.ago)
|
|
92
|
+
joins(:received_votes)
|
|
93
|
+
.where(vote_fu_votes: { created_at: since.. })
|
|
94
|
+
.group(:id)
|
|
95
|
+
.order(Arel.sql("COUNT(vote_fu_votes.id) DESC"))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
module InstanceMethods
|
|
100
|
+
# Count of upvotes
|
|
101
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
102
|
+
# @return [Integer]
|
|
103
|
+
def votes_for(scope: nil)
|
|
104
|
+
received_votes.with_scope(scope).up.count
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Count of downvotes
|
|
108
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
109
|
+
# @return [Integer]
|
|
110
|
+
def votes_against(scope: nil)
|
|
111
|
+
received_votes.with_scope(scope).down.count
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Total number of votes
|
|
115
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
116
|
+
# @return [Integer]
|
|
117
|
+
def votes_count(scope: nil)
|
|
118
|
+
received_votes.with_scope(scope).count
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Sum of all vote values
|
|
122
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
123
|
+
# @return [Integer]
|
|
124
|
+
def votes_total(scope: nil)
|
|
125
|
+
received_votes.with_scope(scope).sum(:value)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Net score (upvotes minus downvotes)
|
|
129
|
+
# Uses counter cache if available
|
|
130
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
131
|
+
# @return [Integer]
|
|
132
|
+
def plusminus(scope: nil)
|
|
133
|
+
if has_attribute?(:votes_total) && scope.nil?
|
|
134
|
+
read_attribute(:votes_total) || 0
|
|
135
|
+
else
|
|
136
|
+
votes_for(scope: scope) - votes_against(scope: scope)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Percentage of upvotes
|
|
141
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
142
|
+
# @return [Float] 0.0 to 100.0
|
|
143
|
+
def percent_for(scope: nil)
|
|
144
|
+
total = votes_count(scope: scope)
|
|
145
|
+
return 0.0 if total.zero?
|
|
146
|
+
|
|
147
|
+
(votes_for(scope: scope).to_f / total * 100).round(1)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Percentage of downvotes
|
|
151
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
152
|
+
# @return [Float] 0.0 to 100.0
|
|
153
|
+
def percent_against(scope: nil)
|
|
154
|
+
total = votes_count(scope: scope)
|
|
155
|
+
return 0.0 if total.zero?
|
|
156
|
+
|
|
157
|
+
(votes_against(scope: scope).to_f / total * 100).round(1)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check if a voter has voted on this item
|
|
161
|
+
# @param voter [ActiveRecord::Base] The voter to check
|
|
162
|
+
# @param direction [Symbol, nil] :up, :down, or nil for any
|
|
163
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
164
|
+
# @return [Boolean]
|
|
165
|
+
def voted_by?(voter, direction: nil, scope: nil)
|
|
166
|
+
vote = received_votes.find_by(voter: voter, scope: scope)
|
|
167
|
+
return false unless vote
|
|
168
|
+
|
|
169
|
+
case direction
|
|
170
|
+
when nil then true
|
|
171
|
+
when :up, :positive then vote.up?
|
|
172
|
+
when :down, :negative then vote.down?
|
|
173
|
+
else false
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get all voters who voted on this item
|
|
178
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
179
|
+
# @return [Array<ActiveRecord::Base>]
|
|
180
|
+
def voters(scope: nil)
|
|
181
|
+
received_votes.with_scope(scope).includes(:voter).map(&:voter).uniq
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Get voters who upvoted
|
|
185
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
186
|
+
# @return [Array<ActiveRecord::Base>]
|
|
187
|
+
def voters_for(scope: nil)
|
|
188
|
+
received_votes.with_scope(scope).up.includes(:voter).map(&:voter).uniq
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Get voters who downvoted
|
|
192
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
193
|
+
# @return [Array<ActiveRecord::Base>]
|
|
194
|
+
def voters_against(scope: nil)
|
|
195
|
+
received_votes.with_scope(scope).down.includes(:voter).map(&:voter).uniq
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Wilson Score Lower Bound
|
|
199
|
+
# @param confidence [Float] Confidence level (0.0 to 1.0)
|
|
200
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
201
|
+
# @return [Float] Score from 0.0 to 1.0
|
|
202
|
+
def wilson_score(confidence: 0.95, scope: nil)
|
|
203
|
+
VoteFu::Algorithms::WilsonScore.call(self, confidence: confidence, scope: scope)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Reddit Hot ranking score
|
|
207
|
+
# @param gravity [Float] Gravity parameter
|
|
208
|
+
# @return [Float]
|
|
209
|
+
def hot_score(gravity: nil)
|
|
210
|
+
gravity ||= VoteFu.configuration.hot_ranking_gravity
|
|
211
|
+
VoteFu::Algorithms::RedditHot.call(self, gravity: gravity)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Counter cache methods
|
|
215
|
+
def increment_vote_counters(value)
|
|
216
|
+
return unless vote_fu_voteable_options[:counter_cache]
|
|
217
|
+
|
|
218
|
+
updates = {}
|
|
219
|
+
updates[:votes_count] = 1 if has_attribute?(:votes_count)
|
|
220
|
+
updates[:votes_total] = value if has_attribute?(:votes_total)
|
|
221
|
+
updates[:upvotes_count] = 1 if has_attribute?(:upvotes_count) && value.positive?
|
|
222
|
+
updates[:downvotes_count] = 1 if has_attribute?(:downvotes_count) && value.negative?
|
|
223
|
+
|
|
224
|
+
self.class.update_counters(id, **updates) if updates.any?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def decrement_vote_counters(value)
|
|
228
|
+
return unless vote_fu_voteable_options[:counter_cache]
|
|
229
|
+
|
|
230
|
+
updates = {}
|
|
231
|
+
updates[:votes_count] = -1 if has_attribute?(:votes_count)
|
|
232
|
+
updates[:votes_total] = -value if has_attribute?(:votes_total)
|
|
233
|
+
updates[:upvotes_count] = -1 if has_attribute?(:upvotes_count) && value.positive?
|
|
234
|
+
updates[:downvotes_count] = -1 if has_attribute?(:downvotes_count) && value.negative?
|
|
235
|
+
|
|
236
|
+
self.class.update_counters(id, **updates) if updates.any?
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def update_vote_counters(old_value, new_value)
|
|
240
|
+
return unless vote_fu_voteable_options[:counter_cache]
|
|
241
|
+
|
|
242
|
+
updates = {}
|
|
243
|
+
|
|
244
|
+
if has_attribute?(:votes_total)
|
|
245
|
+
updates[:votes_total] = new_value - old_value
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if has_attribute?(:upvotes_count)
|
|
249
|
+
old_up = old_value.positive? ? 1 : 0
|
|
250
|
+
new_up = new_value.positive? ? 1 : 0
|
|
251
|
+
updates[:upvotes_count] = new_up - old_up if new_up != old_up
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if has_attribute?(:downvotes_count)
|
|
255
|
+
old_down = old_value.negative? ? 1 : 0
|
|
256
|
+
new_down = new_value.negative? ? 1 : 0
|
|
257
|
+
updates[:downvotes_count] = new_down - old_down if new_down != old_down
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
self.class.update_counters(id, **updates) if updates.any?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Broadcast vote updates via Turbo Streams and ActionCable
|
|
264
|
+
def broadcast_vote_update(vote: nil, action: :updated)
|
|
265
|
+
return unless vote_fu_voteable_options[:broadcasts]
|
|
266
|
+
|
|
267
|
+
# Turbo Streams broadcast
|
|
268
|
+
if respond_to?(:broadcast_replace_to)
|
|
269
|
+
broadcast_replace_to(
|
|
270
|
+
[self, :votes],
|
|
271
|
+
target: "#{self.class.name.underscore}_#{id}_vote_widget",
|
|
272
|
+
partial: "vote_fu/votes/widget",
|
|
273
|
+
locals: { voteable: self }
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# ActionCable broadcast (for custom JS handling)
|
|
278
|
+
if defined?(VoteFu::VotesChannel)
|
|
279
|
+
VoteFu::VotesChannel.broadcast_vote_update(self, vote: vote, action: action)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Subscribe to vote updates via ActionCable
|
|
284
|
+
def vote_stream_name(scope: nil)
|
|
285
|
+
base = "vote_fu:#{self.class.name}:#{id}"
|
|
286
|
+
scope.present? ? "#{base}:#{scope}" : base
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module VoteFu
|
|
6
|
+
module Concerns
|
|
7
|
+
module Voter
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
# Configure a model to cast votes
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# class User < ApplicationRecord
|
|
15
|
+
# acts_as_voter
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example With options
|
|
19
|
+
# class User < ApplicationRecord
|
|
20
|
+
# acts_as_voter allow_self_vote: false
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @param options [Hash] Configuration options
|
|
24
|
+
# @option options [Boolean] :allow_self_vote (false) Allow voting on self
|
|
25
|
+
def acts_as_voter(**options)
|
|
26
|
+
class_attribute :vote_fu_voter_options, default: {
|
|
27
|
+
allow_self_vote: VoteFu.configuration.allow_self_vote
|
|
28
|
+
}.merge(options)
|
|
29
|
+
|
|
30
|
+
has_many :cast_votes,
|
|
31
|
+
class_name: "VoteFu::Vote",
|
|
32
|
+
as: :voter,
|
|
33
|
+
dependent: :destroy,
|
|
34
|
+
inverse_of: :voter
|
|
35
|
+
|
|
36
|
+
include VoteFu::Concerns::Voter::InstanceMethods
|
|
37
|
+
extend VoteFu::Concerns::Voter::ClassMethods
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Declare what models this voter votes on (generates helper methods)
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# class User < ApplicationRecord
|
|
44
|
+
# acts_as_voter
|
|
45
|
+
# votes_on :posts, :comments
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# user.upvote_post(post)
|
|
49
|
+
# user.downvote_comment(comment)
|
|
50
|
+
def votes_on(*model_names, **options)
|
|
51
|
+
acts_as_voter unless respond_to?(:vote_fu_voter_options)
|
|
52
|
+
|
|
53
|
+
model_names.each do |model_name|
|
|
54
|
+
define_voting_methods_for(model_name, **options)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def define_voting_methods_for(model_name, scopes: nil, **)
|
|
61
|
+
singular = model_name.to_s.singularize
|
|
62
|
+
|
|
63
|
+
# user.upvote_post(post)
|
|
64
|
+
define_method(:"upvote_#{singular}") do |voteable, scope: nil|
|
|
65
|
+
vote_on(voteable, value: 1, scope: scope)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# user.downvote_post(post)
|
|
69
|
+
define_method(:"downvote_#{singular}") do |voteable, scope: nil|
|
|
70
|
+
vote_on(voteable, value: -1, scope: scope)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# user.unvote_post(post)
|
|
74
|
+
define_method(:"unvote_#{singular}") do |voteable, scope: nil|
|
|
75
|
+
unvote(voteable, scope: scope)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# user.toggle_vote_post(post)
|
|
79
|
+
define_method(:"toggle_vote_#{singular}") do |voteable, scope: nil|
|
|
80
|
+
toggle_vote(voteable, scope: scope)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# user.vote_on_post(post, value: 5)
|
|
84
|
+
define_method(:"vote_on_#{singular}") do |voteable, value:, scope: nil|
|
|
85
|
+
vote_on(voteable, value: value, scope: scope)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# user.voted_on_post?(post)
|
|
89
|
+
define_method(:"voted_on_#{singular}?") do |voteable, direction: nil, scope: nil|
|
|
90
|
+
voted_on?(voteable, direction: direction, scope: scope)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# user.vote_value_for_post(post)
|
|
94
|
+
define_method(:"vote_value_for_#{singular}") do |voteable, scope: nil|
|
|
95
|
+
vote_value_for(voteable, scope: scope)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generate scoped methods if scopes provided
|
|
99
|
+
scopes&.each do |scope_name|
|
|
100
|
+
define_method(:"vote_on_#{singular}_#{scope_name}") do |voteable, value:|
|
|
101
|
+
vote_on(voteable, value: value, scope: scope_name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
define_method(:"upvote_#{singular}_#{scope_name}") do |voteable|
|
|
105
|
+
vote_on(voteable, value: 1, scope: scope_name)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
define_method(:"downvote_#{singular}_#{scope_name}") do |voteable|
|
|
109
|
+
vote_on(voteable, value: -1, scope: scope_name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
module ClassMethods
|
|
116
|
+
# Find voters who voted on a specific item
|
|
117
|
+
def voted_on(voteable, direction: nil, scope: nil)
|
|
118
|
+
votes = VoteFu::Vote.where(voteable: voteable).with_scope(scope)
|
|
119
|
+
votes = votes.up if direction == :up
|
|
120
|
+
votes = votes.down if direction == :down
|
|
121
|
+
|
|
122
|
+
where(id: votes.where(voter_type: name).select(:voter_id))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
module InstanceMethods
|
|
127
|
+
# Cast a vote on a voteable
|
|
128
|
+
#
|
|
129
|
+
# @param voteable [ActiveRecord::Base] The item to vote on
|
|
130
|
+
# @param value [Integer] Vote value (positive for up, negative for down)
|
|
131
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
132
|
+
# @return [VoteFu::Vote] The created or updated vote
|
|
133
|
+
# @raise [VoteFu::SelfVoteError] If self-voting is disabled
|
|
134
|
+
# @raise [VoteFu::AlreadyVotedError] If already voted and recast disabled
|
|
135
|
+
def vote_on(voteable, value:, scope: nil)
|
|
136
|
+
validate_vote!(voteable, value)
|
|
137
|
+
|
|
138
|
+
existing = find_vote_for(voteable, scope: scope)
|
|
139
|
+
|
|
140
|
+
if existing
|
|
141
|
+
handle_existing_vote(existing, value)
|
|
142
|
+
else
|
|
143
|
+
cast_votes.create!(voteable: voteable, value: value, scope: scope)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Upvote a voteable (+1)
|
|
148
|
+
#
|
|
149
|
+
# @param voteable [ActiveRecord::Base] The item to vote on
|
|
150
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
151
|
+
# @return [VoteFu::Vote]
|
|
152
|
+
def upvote(voteable, scope: nil)
|
|
153
|
+
vote_on(voteable, value: 1, scope: scope)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Downvote a voteable (-1)
|
|
157
|
+
#
|
|
158
|
+
# @param voteable [ActiveRecord::Base] The item to vote on
|
|
159
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
160
|
+
# @return [VoteFu::Vote]
|
|
161
|
+
def downvote(voteable, scope: nil)
|
|
162
|
+
vote_on(voteable, value: -1, scope: scope)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Remove a vote
|
|
166
|
+
#
|
|
167
|
+
# @param voteable [ActiveRecord::Base] The item to unvote
|
|
168
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
169
|
+
# @return [VoteFu::Vote, nil] The destroyed vote or nil
|
|
170
|
+
def unvote(voteable, scope: nil)
|
|
171
|
+
find_vote_for(voteable, scope: scope)&.destroy
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Toggle vote: remove if exists, upvote if not
|
|
175
|
+
#
|
|
176
|
+
# @param voteable [ActiveRecord::Base] The item to toggle
|
|
177
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
178
|
+
# @return [VoteFu::Vote, nil]
|
|
179
|
+
def toggle_vote(voteable, scope: nil)
|
|
180
|
+
existing = find_vote_for(voteable, scope: scope)
|
|
181
|
+
if existing
|
|
182
|
+
existing.destroy
|
|
183
|
+
nil
|
|
184
|
+
else
|
|
185
|
+
upvote(voteable, scope: scope)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Check if voted on an item
|
|
190
|
+
#
|
|
191
|
+
# @param voteable [ActiveRecord::Base] The item to check
|
|
192
|
+
# @param direction [Symbol, Integer, nil] :up, :down, or specific value
|
|
193
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
194
|
+
# @return [Boolean]
|
|
195
|
+
def voted_on?(voteable, direction: nil, scope: nil)
|
|
196
|
+
vote = find_vote_for(voteable, scope: scope)
|
|
197
|
+
return false unless vote
|
|
198
|
+
|
|
199
|
+
case direction
|
|
200
|
+
when nil then true
|
|
201
|
+
when :up, :positive then vote.up?
|
|
202
|
+
when :down, :negative then vote.down?
|
|
203
|
+
when Integer then vote.value == direction
|
|
204
|
+
else false
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Get the vote value for an item
|
|
209
|
+
#
|
|
210
|
+
# @param voteable [ActiveRecord::Base] The item
|
|
211
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
212
|
+
# @return [Integer, nil]
|
|
213
|
+
def vote_value_for(voteable, scope: nil)
|
|
214
|
+
find_vote_for(voteable, scope: scope)&.value
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Get the vote direction for an item
|
|
218
|
+
#
|
|
219
|
+
# @param voteable [ActiveRecord::Base] The item
|
|
220
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
221
|
+
# @return [Symbol, nil] :up, :down, :neutral, or nil
|
|
222
|
+
def vote_direction_for(voteable, scope: nil)
|
|
223
|
+
find_vote_for(voteable, scope: scope)&.direction
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Get all items of a class that this voter voted on
|
|
227
|
+
#
|
|
228
|
+
# @param klass [Class] The voteable class
|
|
229
|
+
# @param scope [Symbol, nil] Optional voting scope
|
|
230
|
+
# @return [ActiveRecord::Relation]
|
|
231
|
+
def voted_items(klass, scope: nil)
|
|
232
|
+
klass.joins(:received_votes)
|
|
233
|
+
.where(vote_fu_votes: { voter: self, scope: scope })
|
|
234
|
+
.distinct
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Count of votes cast
|
|
238
|
+
#
|
|
239
|
+
# @param direction [Symbol, nil] :up, :down, or nil for all
|
|
240
|
+
# @return [Integer]
|
|
241
|
+
def vote_count(direction = nil)
|
|
242
|
+
votes = cast_votes
|
|
243
|
+
case direction
|
|
244
|
+
when :up, :positive then votes.up.count
|
|
245
|
+
when :down, :negative then votes.down.count
|
|
246
|
+
else votes.count
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def find_vote_for(voteable, scope: nil)
|
|
253
|
+
cast_votes.find_by(voteable: voteable, scope: scope)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_vote!(voteable, value)
|
|
257
|
+
if voteable == self && !vote_fu_voter_options[:allow_self_vote]
|
|
258
|
+
raise VoteFu::SelfVoteError
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
raise VoteFu::InvalidVoteValueError unless value.is_a?(Integer)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def handle_existing_vote(existing, value)
|
|
265
|
+
if VoteFu.configuration.allow_recast
|
|
266
|
+
existing.update!(value: value)
|
|
267
|
+
existing
|
|
268
|
+
else
|
|
269
|
+
raise VoteFu::AlreadyVotedError
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
class Configuration
|
|
5
|
+
# Can voters change their vote after casting?
|
|
6
|
+
attr_accessor :allow_recast
|
|
7
|
+
|
|
8
|
+
# Can voters cast multiple votes on the same item?
|
|
9
|
+
attr_accessor :allow_duplicate_votes
|
|
10
|
+
|
|
11
|
+
# Can a model vote on itself?
|
|
12
|
+
attr_accessor :allow_self_vote
|
|
13
|
+
|
|
14
|
+
# Automatically maintain counter cache columns?
|
|
15
|
+
attr_accessor :counter_cache
|
|
16
|
+
|
|
17
|
+
# Broadcast vote changes via Turbo Streams?
|
|
18
|
+
attr_accessor :turbo_broadcasts
|
|
19
|
+
|
|
20
|
+
# Use ActionCable for real-time updates?
|
|
21
|
+
attr_accessor :action_cable
|
|
22
|
+
|
|
23
|
+
# Default ranking algorithm (:wilson_score, :reddit_hot, :hacker_news, :simple)
|
|
24
|
+
attr_accessor :default_ranking
|
|
25
|
+
|
|
26
|
+
# Gravity parameter for hot ranking algorithms
|
|
27
|
+
attr_accessor :hot_ranking_gravity
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@allow_recast = true
|
|
31
|
+
@allow_duplicate_votes = false
|
|
32
|
+
@allow_self_vote = false
|
|
33
|
+
@counter_cache = true
|
|
34
|
+
@turbo_broadcasts = true
|
|
35
|
+
@action_cable = true
|
|
36
|
+
@default_ranking = :wilson_score
|
|
37
|
+
@hot_ranking_gravity = 1.8
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
{
|
|
42
|
+
allow_recast: allow_recast,
|
|
43
|
+
allow_duplicate_votes: allow_duplicate_votes,
|
|
44
|
+
allow_self_vote: allow_self_vote,
|
|
45
|
+
counter_cache: counter_cache,
|
|
46
|
+
turbo_broadcasts: turbo_broadcasts,
|
|
47
|
+
action_cable: action_cable,
|
|
48
|
+
default_ranking: default_ranking,
|
|
49
|
+
hot_ranking_gravity: hot_ranking_gravity
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Only load engine when Rails is present
|
|
4
|
+
if defined?(Rails::Engine)
|
|
5
|
+
require "turbo-rails"
|
|
6
|
+
|
|
7
|
+
module VoteFu
|
|
8
|
+
class Engine < ::Rails::Engine
|
|
9
|
+
isolate_namespace VoteFu
|
|
10
|
+
|
|
11
|
+
# Load concerns when ActiveRecord is ready
|
|
12
|
+
initializer "vote_fu.active_record" do
|
|
13
|
+
ActiveSupport.on_load(:active_record) do
|
|
14
|
+
require "vote_fu/concerns/voteable"
|
|
15
|
+
require "vote_fu/concerns/voter"
|
|
16
|
+
require "vote_fu/concerns/karmatic"
|
|
17
|
+
|
|
18
|
+
include VoteFu::Concerns::Voteable
|
|
19
|
+
include VoteFu::Concerns::Voter
|
|
20
|
+
include VoteFu::Concerns::Karmatic
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Include helpers in ActionView
|
|
25
|
+
initializer "vote_fu.helpers" do
|
|
26
|
+
ActiveSupport.on_load(:action_view) do
|
|
27
|
+
include VoteFu::VotesHelper
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Set up importmap for Stimulus controllers
|
|
32
|
+
initializer "vote_fu.importmap", before: "importmap" do |app|
|
|
33
|
+
if app.config.respond_to?(:importmap)
|
|
34
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
|
35
|
+
app.config.importmap.cache_sweepers << Engine.root.join("app/javascript")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Append engine assets to asset pipeline
|
|
40
|
+
initializer "vote_fu.assets" do |app|
|
|
41
|
+
if app.config.respond_to?(:assets)
|
|
42
|
+
app.config.assets.paths << Engine.root.join("app/assets/stylesheets")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Configure generators
|
|
47
|
+
config.generators do |g|
|
|
48
|
+
g.test_framework :rspec
|
|
49
|
+
g.fixture_replacement :factory_bot
|
|
50
|
+
g.factory_bot dir: "spec/factories"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoteFu
|
|
4
|
+
# Base error class for all VoteFu errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a voter attempts to vote again without recast enabled
|
|
8
|
+
class AlreadyVotedError < Error
|
|
9
|
+
def initialize(msg = "Already voted on this item")
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Raised when a model attempts to vote on itself
|
|
15
|
+
class SelfVoteError < Error
|
|
16
|
+
def initialize(msg = "Cannot vote on yourself")
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when an invalid vote value is provided
|
|
22
|
+
class InvalidVoteValueError < Error
|
|
23
|
+
def initialize(msg = "Vote value must be an integer")
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when voteable is not found
|
|
29
|
+
class VoteableNotFoundError < Error
|
|
30
|
+
def initialize(msg = "Voteable not found")
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|