diff --git a/app/assets/stylesheets/ltags/LiquidTags.scss b/app/assets/stylesheets/ltags/LiquidTags.scss
index 82024f19a..89f6eda72 100644
--- a/app/assets/stylesheets/ltags/LiquidTags.scss
+++ b/app/assets/stylesheets/ltags/LiquidTags.scss
@@ -10,3 +10,4 @@
@import 'InstagramTag';
@import 'GistTag';
@import 'GithubReadmeTag';
+@import 'PollTag';
diff --git a/app/assets/stylesheets/ltags/PollTag.scss b/app/assets/stylesheets/ltags/PollTag.scss
new file mode 100644
index 000000000..e37fabd88
--- /dev/null
+++ b/app/assets/stylesheets/ltags/PollTag.scss
@@ -0,0 +1,85 @@
+@import 'variables';
+
+.ltag-poll {
+ width: 600px;
+ max-width: 96%;
+ margin: auto;
+ border-radius: 3px;
+ border:1px solid $light-medium-gray !important;
+ box-shadow: $shadow;
+ box-sizing: border-box;
+ padding: 2px 15px 18px;
+ font-family: $helvetica;
+ margin:1.6em auto !important;
+ h3 {
+ margin: 15px 0px;
+ }
+ .ltag-pollanswers {
+ list-style-type: none !important;
+ margin: 0 !important; /* To remove default bottom margin */
+ padding: 0 !important;
+ padding-bottom: 8px !important;
+ font-size: 0.9em !important;
+ li {
+ padding-left: 8px;
+ border-radius: 3px;
+ width: 95%;
+ position: relative;
+ &:hover {
+ background: $light-gray;
+ }
+ &.ltag-polloption-justshowmetheresults:hover {
+ background: transparent;
+ }
+ &.already-voted {
+ background: transparent;
+ }
+ }
+ input {
+ vertical-align: top;
+ margin-top: 1.05em;
+ }
+ label {
+ width: calc(100% - 40px);
+ padding: 2px 6px;
+ cursor: pointer;
+ vertical-align: -3px;
+ display: inline-block;
+ line-height: 1.25em;
+ }
+ .ltag-voting-results-count {
+ font-size: 0.7em;
+ color: $medium-gray;
+ text-align: left;
+ }
+ button {
+ width: 100%;
+ background: transparent;
+ border: 0px;
+ font-size: 0.7em;
+ color: $medium-gray;
+ text-align: left;
+ padding-left: 20px;
+ }
+ .ltag-votepercent {
+ display: inline-block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ z-index: 3;
+ border-radius: 3px;
+ &.ltag-optionvotedfor {
+ background: $green;
+ font-weight: bold;
+ }
+ &.ltag-optionnotvotedfor {
+ background: $light-medium-gray;
+ }
+ }
+ .ltag-votepercenttext {
+ position: relative;
+ z-index: 5;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/controllers/poll_skips_controller.rb b/app/controllers/poll_skips_controller.rb
new file mode 100644
index 000000000..1d6c8abcf
--- /dev/null
+++ b/app/controllers/poll_skips_controller.rb
@@ -0,0 +1,19 @@
+class PollSkipsController < ApplicationController
+ before_action :authenticate_user!, only: %i[create]
+
+ def create
+ @poll_skip = PollSkip.create(poll_id: poll_skips_params[:poll_id], user_id: current_user.id)
+ @poll = Poll.find(poll_skips_params[:poll_id])
+ render json: { voting_data: @poll.voting_data,
+ poll_id: poll_skips_params[:poll_id].to_i,
+ user_vote_poll_option_id: nil,
+ voted: false }
+ end
+
+ private
+
+ def poll_skips_params
+ accessible = %i[poll_id]
+ params.require(:poll_skip).permit(accessible)
+ end
+end
diff --git a/app/controllers/poll_votes_controller.rb b/app/controllers/poll_votes_controller.rb
new file mode 100644
index 000000000..80dce9fa4
--- /dev/null
+++ b/app/controllers/poll_votes_controller.rb
@@ -0,0 +1,30 @@
+class PollVotesController < ApplicationController
+ before_action :authenticate_user!, only: %i[create]
+
+ def show
+ @poll = Poll.find(params[:id]) #Querying the poll instead of the poll vote
+ @poll_vote = @poll.poll_votes.where(user_id: current_user).first
+ @poll_skip = @poll.poll_skips.where(user_id: current_user).first if @poll_vote.blank?
+ render json: { voting_data: @poll.voting_data,
+ poll_id: @poll.id,
+ user_vote_poll_option_id: @poll_vote&.poll_option_id,
+ voted: (@poll_vote || @poll_skip).present? }
+ end
+
+ def create
+ @poll_option = PollOption.find(poll_vote_params[:poll_option_id])
+ @poll_vote = PollVote.create(poll_option_id: @poll_option&.id, user_id: current_user.id, poll_id: @poll_option.poll_id)
+ @poll = @poll_option.reload.poll
+ render json: { voting_data: @poll.voting_data,
+ poll_id: @poll.id,
+ user_vote_poll_option_id: poll_vote_params[:poll_option_id].to_i,
+ voted: true }
+ end
+
+ private
+
+ def poll_vote_params
+ accessible = %i[poll_option_id]
+ params.require(:poll_vote).permit(accessible)
+ end
+end
diff --git a/app/decorators/article_decorator.rb b/app/decorators/article_decorator.rb
index 58defc84d..afced4191 100644
--- a/app/decorators/article_decorator.rb
+++ b/app/decorators/article_decorator.rb
@@ -25,10 +25,6 @@ class ArticleDecorator < ApplicationDecorator
"https://#{ApplicationConfig['APP_DOMAIN']}#{path}"
end
- def liquid_tags_used
- MarkdownParser.new(body_markdown.to_s + comments_blob.to_s).tags_used
- end
-
def title_length_classification
if article.title.size > 105
"longest"
diff --git a/app/labor/markdown_parser.rb b/app/labor/markdown_parser.rb
index 56420d43f..3a59d35fe 100644
--- a/app/labor/markdown_parser.rb
+++ b/app/labor/markdown_parser.rb
@@ -58,6 +58,18 @@ class MarkdownParser
attributes: allowed_attributes
end
+ def evaluate_inline_limited_markdown
+ return if @content.blank?
+
+ renderer = Redcarpet::Render::HTMLRouge.new(hard_wrap: true, filter_html: false)
+ markdown = Redcarpet::Markdown.new(renderer, REDCARPET_CONFIG)
+ allowed_tags = %w[strong i u b em code]
+ allowed_attributes = %w[href strong em ref rel src title alt class]
+ ActionController::Base.helpers.sanitize markdown.render(@content).html_safe,
+ tags: allowed_tags,
+ attributes: allowed_attributes
+ end
+
def evaluate_listings_markdown
return if @content.blank?
diff --git a/app/liquid_tags/poll_tag.rb b/app/liquid_tags/poll_tag.rb
new file mode 100644
index 000000000..8c6fb3399
--- /dev/null
+++ b/app/liquid_tags/poll_tag.rb
@@ -0,0 +1,121 @@
+class PollTag < LiquidTagBase
+ PARTIAL = "liquids/poll".freeze
+
+ def initialize(_tag_name, id_code, _tokens)
+ @poll = Poll.find(id_code)
+ end
+
+ def render(_context)
+ ActionController::Base.new.render_to_string(
+ partial: PARTIAL,
+ locals: {
+ poll: @poll
+ },
+ )
+ end
+
+ def find_poll(id_code)
+ Poll.find(id_code.to_i(26))
+ rescue ActiveRecord::RecordNotFound
+ raise StandardError, "Invalid poll ID"
+ end
+
+ def self.script
+ <<~JAVASCRIPT
+ if (document.head.querySelector(
+ 'meta[name="user-signed-in"][content="true"]',
+ )) {
+
+ function displayPollResults(json) {
+ var totalVotes = json.voting_data.votes_count;
+ json.voting_data.votes_distribution.forEach(function(point) {
+ var pollOptionItem = document.getElementById('poll_option_list_item_'+point[0]);
+ var optionText = document.getElementById('poll_option_label_'+point[0]).textContent;
+ if (json.user_vote_poll_option_id === point[0]) {
+ var votedClass = 'optionvotedfor'
+ } else {
+ var votedClass = 'optionnotvotedfor'
+ }
+ if (totalVotes === 0) {
+ var percent = 0;
+ } else {
+ var percent = (point[1]/totalVotes)*100;
+ }
+ var roundedPercent = Math.round( percent * 10 ) / 10
+ var percentFromRight = (100-roundedPercent)
+ var html = '\
+ '+optionText+' — '+roundedPercent+'%';
+ pollOptionItem.innerHTML = html;
+ pollOptionItem.classList.add('already-voted')
+ document.getElementById('showmethemoney-'+json.poll_id).innerHTML = ''+totalVotes+' total votes';
+ })
+ }
+
+ var polls = document.getElementsByClassName('ltag-poll');
+ for (i = 0; i < polls.length; i += 1) {
+ var poll = polls[i]
+ var pollId = poll.dataset.pollId
+ window.fetch('/poll_votes/'+pollId)
+ .then(function(response){
+ response.json().then(
+ function(json){
+ if (json.voted) {
+ displayPollResults(json)
+ } else {
+ var els = document.getElementById('poll_'+json.poll_id).getElementsByClassName('ltag-polloption')
+ for (i = 0; i < els.length; i += 1) {
+ els[i].addEventListener('click', function(e) {
+ var tokenMeta = document.querySelector("meta[name='csrf-token']")
+ if (!tokenMeta) {
+ alert('Whoops. There was an error. Your vote was not counted. Try refreshing the page.')
+ return
+ }
+ var csrfToken = tokenMeta.getAttribute('content')
+ var optionId = e.target.dataset.optionId
+ window.fetch('/poll_votes', {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': csrfToken,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({poll_vote: { poll_option_id: optionId } }),
+ credentials: 'same-origin',
+ }).then(function(response){
+ response.json().then(function(j){displayPollResults(j)})
+ })
+ });
+ }
+ document.getElementById('showmethemoney-'+json.poll_id).addEventListener('click', function(e) {
+ pollId = this.dataset.pollId
+ window.fetch('/poll_skips', {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': csrfToken,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({poll_skip: {poll_id: pollId }}),
+ credentials: 'same-origin',
+ }).then(function(response){
+ response.json().then(function(j){displayPollResults(j)})
+ })
+ });
+ }
+ }
+ )
+ })
+ }
+ } else {
+ var els = document.getElementsByClassName('ltag-poll')
+ for (i = 0; i < els.length; i += 1) {
+ els[i].onclick = function(e) {
+ if (typeof showModal !== "undefined") {
+ showModal('poll');
+ }
+ }
+ }
+ }
+ JAVASCRIPT
+ end
+end
+
+Liquid::Template.register_tag("poll", PollTag)
diff --git a/app/models/article.rb b/app/models/article.rb
index 2a5cfb326..cdfd4a54d 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -42,6 +42,7 @@ class Article < ApplicationRecord
validate :validate_tag
validate :validate_video
validate :validate_collection_permission
+ validate :validate_liquid_tag_permissions
validates :video_state, inclusion: { in: %w[PROGRESSING COMPLETED] }, allow_nil: true
validates :cached_tag_list, length: { maximum: 86 }
validates :main_image, url: { allow_blank: true, schemes: %w[https http] }
@@ -444,6 +445,10 @@ class Article < ApplicationRecord
Rails.logger.error(e)
end
+ def liquid_tags_used
+ MarkdownParser.new(body_markdown.to_s + comments_blob.to_s).tags_used
+ end
+
private
def update_notifications
@@ -519,6 +524,10 @@ class Article < ApplicationRecord
errors.add(:collection_id, "must be one you have permission to post to") if collection && collection.user_id != user_id
end
+ def validate_liquid_tag_permissions #Admin only beta tags etc.
+ errors.add(:body_markdown, "must only use permitted tags") if liquid_tags_used.include?(PollTag) && !(user.has_role?(:super_admin) || user.has_role?(:admin))
+ end
+
def create_slug
if slug.blank? && title.present? && !published
self.slug = title_to_slug + "-temp-slug-#{rand(10_000_000)}"
diff --git a/app/models/poll.rb b/app/models/poll.rb
new file mode 100644
index 000000000..636f24bce
--- /dev/null
+++ b/app/models/poll.rb
@@ -0,0 +1,35 @@
+class Poll < ApplicationRecord
+
+ attr_accessor :poll_options_input_array
+
+ serialize :voting_data
+
+ belongs_to :article
+ has_many :poll_options
+ has_many :poll_skips
+ has_many :poll_votes
+
+ validates :prompt_markdown, presence: true,
+ length: { maximum: 128 }
+ validates :poll_options_input_array, presence: true,
+ length: { minimum: 2, maximum: 15 }
+
+ after_create :create_poll_options
+ before_save :evaluate_markdown
+
+ def voting_data
+ { votes_count: poll_votes_count, votes_distribution: poll_options.pluck(:id, :poll_votes_count) }
+ end
+
+ private
+
+ def create_poll_options
+ poll_options_input_array.each do |input|
+ PollOption.create!(markdown: input, poll_id: id)
+ end
+ end
+
+ def evaluate_markdown
+ self.prompt_html = MarkdownParser.new(prompt_markdown).evaluate_inline_limited_markdown
+ end
+end
diff --git a/app/models/poll_option.rb b/app/models/poll_option.rb
new file mode 100644
index 000000000..96436d011
--- /dev/null
+++ b/app/models/poll_option.rb
@@ -0,0 +1,17 @@
+class PollOption < ApplicationRecord
+ belongs_to :poll
+ has_many :poll_votes
+
+ validates :markdown, presence: true,
+ length: { maximum: 128 }
+
+ before_save :evaluate_markdown
+
+ counter_culture :poll
+
+ private
+
+ def evaluate_markdown
+ self.processed_html = MarkdownParser.new(markdown).evaluate_inline_limited_markdown
+ end
+end
diff --git a/app/models/poll_skip.rb b/app/models/poll_skip.rb
new file mode 100644
index 000000000..2a5f79e9b
--- /dev/null
+++ b/app/models/poll_skip.rb
@@ -0,0 +1,12 @@
+class PollSkip < ApplicationRecord
+ belongs_to :poll
+ belongs_to :user
+
+ validate :one_vote_per_poll_per_user
+
+ private
+
+ def one_vote_per_poll_per_user
+ errors.add(:base, "cannot vote more than once in one poll") if (poll.poll_votes.where(user_id: user_id).any? || poll.poll_skips.where(user_id: user_id).any?)
+ end
+end
diff --git a/app/models/poll_vote.rb b/app/models/poll_vote.rb
new file mode 100644
index 000000000..b9f3110d1
--- /dev/null
+++ b/app/models/poll_vote.rb
@@ -0,0 +1,30 @@
+class PollVote < ApplicationRecord
+ belongs_to :user
+ belongs_to :poll_option
+ belongs_to :poll
+
+ counter_culture :poll_option
+ counter_culture :poll
+
+ validates :poll_id, presence: true, presence: true, uniqueness: { scope: :user_id } # In the future we'll remove this constraint if/when we allow multi-answer polls
+ validates :poll_option_id, presence: true, uniqueness: { scope: :user_id }
+ validate :one_vote_per_poll_per_user
+
+ after_save :touch_poll_votes_count
+ after_destroy :touch_poll_votes_count
+
+ def poll
+ poll_option.poll
+ end
+
+ private
+
+ def one_vote_per_poll_per_user
+ errors.add(:base, "cannot vote more than once in one poll") if poll.poll_votes.where(user_id: user_id).any? || poll.poll_skips.where(user_id: user_id).any?
+ end
+
+ def touch_poll_votes_count
+ poll.update_column(:poll_votes_count, poll.poll_votes.size)
+ poll_option.update_column(:poll_votes_count, poll_option.poll_votes.size)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index f7a72201c..bb686ac7e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -38,6 +38,8 @@ class User < ApplicationRecord
has_many :page_views
has_many :credits
has_many :classified_listings
+ has_many :poll_votes
+ has_many :poll_skips
has_many :mentor_relationships_as_mentee,
class_name: "MentorRelationship", foreign_key: "mentee_id", inverse_of: :mentee
has_many :mentor_relationships_as_mentor,
diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb
index a73bdd525..d065ffab2 100644
--- a/app/views/articles/show.html.erb
+++ b/app/views/articles/show.html.erb
@@ -236,6 +236,7 @@
<%= PodcastTag.script.html_safe %>
<%= GistTag.script.html_safe %>
<%= RunkitTag.script.html_safe %>
+ <%= PollTag.script.html_safe %>
<% end %>
diff --git a/app/views/liquids/_poll.html.erb b/app/views/liquids/_poll.html.erb
new file mode 100644
index 000000000..0a6c2d0d2
--- /dev/null
+++ b/app/views/liquids/_poll.html.erb
@@ -0,0 +1,12 @@
+
+
<%= poll.prompt_html.html_safe %>
+
+
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index d78550603..a258cc50d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -143,6 +143,8 @@ Rails.application.routes.draw do
resources :credits, only: %i[index new create]
resources :buffer_updates, only: [:create]
resources :reading_list_items, only: [:update]
+ resources :poll_votes, only: %i[show create]
+ resources :poll_skips, only: [:create]
get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
get "/credits/purchase" => "credits#new"
diff --git a/db/migrate/20190525233909_create_polls.rb b/db/migrate/20190525233909_create_polls.rb
new file mode 100644
index 000000000..62596d227
--- /dev/null
+++ b/db/migrate/20190525233909_create_polls.rb
@@ -0,0 +1,14 @@
+class CreatePolls < ActiveRecord::Migration[5.2]
+ def change
+ create_table :polls do |t|
+ t.bigint :article_id
+ t.string :prompt_markdown
+ t.string :prompt_html
+ t.boolean :allow_multiple_selections, default: false
+ t.integer :poll_options_count, null: false, default: 0
+ t.integer :poll_votes_count, null: false, default: 0
+ t.integer :poll_skips_count, null: false, default: 0
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20190525233918_create_poll_options.rb b/db/migrate/20190525233918_create_poll_options.rb
new file mode 100644
index 000000000..7df70560e
--- /dev/null
+++ b/db/migrate/20190525233918_create_poll_options.rb
@@ -0,0 +1,12 @@
+class CreatePollOptions < ActiveRecord::Migration[5.2]
+ def change
+ create_table :poll_options do |t|
+ t.bigint :poll_id
+ t.string :markdown
+ t.string :processed_html
+ t.boolean :counts_in_tabulation
+ t.integer :poll_votes_count, null: false, default: 0
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20190525233934_create_poll_votes.rb b/db/migrate/20190525233934_create_poll_votes.rb
new file mode 100644
index 000000000..822f2c5e8
--- /dev/null
+++ b/db/migrate/20190525233934_create_poll_votes.rb
@@ -0,0 +1,13 @@
+class CreatePollVotes < ActiveRecord::Migration[5.2]
+ def change
+ create_table :poll_votes do |t|
+ t.bigint :user_id, null: false
+ t.bigint :poll_id, null: false
+ t.bigint :poll_option_id, null: false
+ t.timestamps
+ end
+ add_index :poll_votes, %i[poll_option_id user_id],
+ unique: true,
+ name: "index_poll_votes_on_poll_option_and_user"
+ end
+end
diff --git a/db/migrate/20190611195955_create_poll_skips.rb b/db/migrate/20190611195955_create_poll_skips.rb
new file mode 100644
index 000000000..507e74a45
--- /dev/null
+++ b/db/migrate/20190611195955_create_poll_skips.rb
@@ -0,0 +1,12 @@
+class CreatePollSkips < ActiveRecord::Migration[5.2]
+ def change
+ create_table :poll_skips do |t|
+ t.bigint :user_id
+ t.bigint :poll_id
+ t.timestamps
+ end
+ add_index :poll_skips, %i[poll_id user_id],
+ unique: true,
+ name: "index_poll_skips_on_poll_and_user"
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 458a4b8b1..098dbead6 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -685,6 +685,45 @@ ActiveRecord::Schema.define(version: 2019_06_14_093041) do
t.index ["slug"], name: "index_podcasts_on_slug", unique: true
end
+ create_table "poll_options", force: :cascade do |t|
+ t.boolean "counts_in_tabulation"
+ t.datetime "created_at", null: false
+ t.string "markdown"
+ t.bigint "poll_id"
+ t.integer "poll_votes_count", default: 0, null: false
+ t.string "processed_html"
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "poll_skips", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "poll_id"
+ t.datetime "updated_at", null: false
+ t.bigint "user_id"
+ t.index ["poll_id", "user_id"], name: "index_poll_skips_on_poll_and_user", unique: true
+ end
+
+ create_table "poll_votes", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "poll_id", null: false
+ t.bigint "poll_option_id", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["poll_option_id", "user_id"], name: "index_poll_votes_on_poll_option_and_user", unique: true
+ end
+
+ create_table "polls", force: :cascade do |t|
+ t.boolean "allow_multiple_selections", default: false
+ t.bigint "article_id"
+ t.datetime "created_at", null: false
+ t.integer "poll_options_count", default: 0, null: false
+ t.integer "poll_skips_count", default: 0, null: false
+ t.integer "poll_votes_count", default: 0, null: false
+ t.string "prompt_html"
+ t.string "prompt_markdown"
+ t.datetime "updated_at", null: false
+ end
+
create_table "push_notification_subscriptions", force: :cascade do |t|
t.string "auth_key"
t.datetime "created_at", null: false
diff --git a/spec/factories/poll_options.rb b/spec/factories/poll_options.rb
new file mode 100644
index 000000000..fca3faa01
--- /dev/null
+++ b/spec/factories/poll_options.rb
@@ -0,0 +1,5 @@
+FactoryBot.define do
+ factory :poll_option do
+ markdown { Faker::Hipster.words(3) }
+ end
+end
diff --git a/spec/factories/poll_skips.rb b/spec/factories/poll_skips.rb
new file mode 100644
index 000000000..0bfdb36fc
--- /dev/null
+++ b/spec/factories/poll_skips.rb
@@ -0,0 +1,10 @@
+FactoryBot.define do
+ factory :poll_skip do
+ # prompt_markdown { Faker::Hipster.words(5) }
+ # factory :poll_with_options do
+ # after(:create) do |poll|
+ # create_list(:poll_option, poll: poll)
+ # end
+ # end
+ end
+end
diff --git a/spec/factories/poll_votes.rb b/spec/factories/poll_votes.rb
new file mode 100644
index 000000000..b636322e3
--- /dev/null
+++ b/spec/factories/poll_votes.rb
@@ -0,0 +1,4 @@
+FactoryBot.define do
+ factory :poll_vote do
+ end
+end
diff --git a/spec/factories/polls.rb b/spec/factories/polls.rb
new file mode 100644
index 000000000..e42bd3af0
--- /dev/null
+++ b/spec/factories/polls.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :poll do
+ prompt_markdown { Faker::Hipster.words(5) }
+ poll_options_input_array { [rand(5).to_s, rand(5).to_s, rand(5).to_s, rand(5).to_s] }
+ end
+end
diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb
index cfb417a33..23baec07f 100644
--- a/spec/models/article_spec.rb
+++ b/spec/models/article_spec.rb
@@ -413,6 +413,19 @@ RSpec.describe Article, type: :model do
article.main_image = "https://image.com/image.png"
expect(article.valid?).to eq(true)
end
+
+ it "does not allow the use of admin-only liquid tags for non-admins" do
+ poll = create(:poll, article_id: article.id)
+ article.body_markdown = "hello hey hey hey {% poll #{poll.id} %}"
+ expect(article.valid?).to eq(false)
+ end
+
+ it "allows admins" do
+ poll = create(:poll, article_id: article.id)
+ article.user.add_role(:admin)
+ article.body_markdown = "hello hey hey hey {% poll #{poll.id} %}"
+ expect(article.valid?).to eq(true)
+ end
end
it "updates main_image_background_hex_color" do
diff --git a/spec/models/poll_option_spec.rb b/spec/models/poll_option_spec.rb
new file mode 100644
index 000000000..eeca4714d
--- /dev/null
+++ b/spec/models/poll_option_spec.rb
@@ -0,0 +1,15 @@
+require "rails_helper"
+
+RSpec.describe PollOption, type: :model do
+ let(:article) { create(:article, featured: true) }
+ let(:poll) { create(:poll, article_id: article.id) }
+
+ it "allows up to 128 markdown characters" do
+ poll_option = PollOption.create(markdown: "0" * 30, poll_id: poll.id)
+ expect(poll_option).to be_valid
+ end
+ it "disallows over 128 markdown characters" do
+ poll_option = PollOption.create(markdown: "0" * 200, poll_id: poll.id)
+ expect(poll_option).not_to be_valid
+ end
+end
diff --git a/spec/models/poll_skip_spec.rb b/spec/models/poll_skip_spec.rb
new file mode 100644
index 000000000..10608b660
--- /dev/null
+++ b/spec/models/poll_skip_spec.rb
@@ -0,0 +1,35 @@
+require 'rails_helper'
+
+RSpec.describe PollSkip, type: :model do
+ let(:article) { create(:article, featured: true) }
+ let(:user) { create(:user) }
+ let(:poll) { create(:poll, article_id: article.id) }
+
+ it "is unique across poll and user" do
+ PollSkip.create(user_id: user.id, poll_id: poll.id)
+ PollSkip.create(user_id: user.id, poll_id: poll.id)
+ PollSkip.create(user_id: user.id, poll_id: poll.id)
+ expect(PollSkip.all.size).to eq(1)
+ second_poll = create(:poll, article_id: article.id)
+ PollSkip.create(user_id: user.id, poll_id: second_poll.id)
+ expect(PollSkip.all.size).to eq(2)
+ end
+
+ it "is unique across user and poll votes for the poll" do
+ PollVote.create(user_id: user.id, poll_id: poll.id, poll_option_id: poll.poll_options.last.id)
+ PollSkip.create(user_id: user.id, poll_id: poll.id)
+ PollSkip.create(user_id: user.id, poll_id: poll.id)
+ expect(PollSkip.all.size).to eq(0)
+ second_poll = create(:poll, article_id: article.id)
+ PollSkip.create(user_id: user.id, poll_id: second_poll.id)
+ expect(PollSkip.all.size).to eq(1)
+ end
+
+ it "is prevents a poll vote from being cast" do
+ PollSkip.create(user_id: user.id, poll_id: poll.id)
+ PollSkip.create(user_id: user.id, poll_id: poll.id)
+ PollVote.create(user_id: user.id, poll_id: poll.id, poll_option_id: poll.poll_options.last.id)
+ expect(PollSkip.all.size).to eq(1)
+ expect(PollVote.all.size).to eq(0)
+ end
+end
diff --git a/spec/models/poll_spec.rb b/spec/models/poll_spec.rb
new file mode 100644
index 000000000..656d04d8c
--- /dev/null
+++ b/spec/models/poll_spec.rb
@@ -0,0 +1,17 @@
+require "rails_helper"
+
+RSpec.describe Poll, type: :model do
+ let(:article) { create(:article, featured: true) }
+ let(:poll) { create(:poll, article_id: article.id) }
+
+ it "limits length of prompt" do
+ long_string = "0" * 200
+ poll.prompt_markdown = long_string
+ expect(poll).not_to be_valid
+ end
+
+ it "creates options from input" do
+ poll = create(:poll, article_id: article.id, poll_options_input_array: %w[hello goodbye heyheyhey])
+ expect(poll.poll_options.size).to eq(3)
+ end
+end
diff --git a/spec/models/poll_vote_spec.rb b/spec/models/poll_vote_spec.rb
new file mode 100644
index 000000000..d4d796206
--- /dev/null
+++ b/spec/models/poll_vote_spec.rb
@@ -0,0 +1,54 @@
+require "rails_helper"
+
+RSpec.describe PollVote, type: :model do
+ let(:article) { create(:article, featured: true) }
+ let(:user) { create(:user) }
+ let(:poll) { create(:poll, article_id: article.id) }
+
+ it "limits one vote per user per poll" do
+ create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ PollVote.create(poll_option_id: poll.poll_options.first.id, user_id: user.id, poll_id: poll.id)
+ PollVote.create(poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ expect(user.poll_votes.size).to eq(1)
+ end
+
+ it "allows one vote per user across multiple polls" do
+ second_poll = create(:poll, article_id: article.id)
+ create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ create(:poll_vote, poll_option_id: second_poll.poll_options.last.id, user_id: user.id, poll_id: second_poll.id)
+ expect(user.reload.poll_votes.size).to eq(2)
+ end
+
+ it "allows multiple people to vote in one poll" do
+ second_user = create(:user)
+ create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: second_user.id, poll_id: poll.id)
+ expect(user.poll_votes.size).to eq(1)
+ expect(second_user.poll_votes.size).to eq(1)
+ end
+
+ it "disallows a user to skip poll after voting" do
+ create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ PollSkip.create(poll_id: poll.id, user_id: user.id)
+ expect(user.poll_skips.size).to eq(0)
+ end
+
+ it "disallows a vote after skipping" do
+ PollSkip.create(poll_id: poll.id, user_id: user.id)
+ PollVote.create(poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ expect(user.poll_votes.size).to eq(0)
+ end
+
+ it "updates poll voting count after create" do
+ create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ expect(poll.reload.poll_votes_count).to eq(1)
+ expect(poll.reload.poll_options.last.poll_votes_count).to eq(1)
+ end
+
+ it "updates poll voting count after create with accuracy alongside skips" do
+ create(:poll_vote, poll_option_id: poll.poll_options.last.id, user_id: user.id, poll_id: poll.id)
+ PollSkip.create(poll_id: poll.id, user_id: user.id)
+ expect(poll.reload.poll_votes_count).to eq(1)
+ expect(poll.reload.poll_options.last.poll_votes_count).to eq(1)
+ end
+end
diff --git a/spec/requests/poll_skips_spec.rb b/spec/requests/poll_skips_spec.rb
new file mode 100644
index 000000000..d9b7af728
--- /dev/null
+++ b/spec/requests/poll_skips_spec.rb
@@ -0,0 +1,32 @@
+require "rails_helper"
+
+RSpec.describe "PollSkips", type: :request do
+ let(:user) { create(:user) }
+ let(:article) { create(:article) }
+ let(:poll) { create(:poll, article_id: article.id) }
+
+ before { sign_in user }
+
+ describe "POST /poll_votes" do
+ it "votes on behalf of current user" do
+ post "/poll_skips", params: {
+ poll_skip: { poll_id: poll.id }
+ }
+ expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(0)
+ expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 0])
+ expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id)
+ expect(JSON.parse(response.body)["voted"]).to eq(false)
+ expect(user.poll_skips.size).to eq(1)
+ end
+ it "only allows one of vote or skip" do
+ post "/poll_skips", params: {
+ poll_skip: { poll_id: poll.id }
+ }
+ post "/poll_votes", params: {
+ poll_vote: { poll_option_id: poll.poll_options.first.id }
+ }
+ expect(user.poll_skips.size).to eq(1)
+ expect(user.poll_votes.size).to eq(0)
+ end
+ end
+end
diff --git a/spec/requests/poll_votes_spec.rb b/spec/requests/poll_votes_spec.rb
new file mode 100644
index 000000000..e08ed0c40
--- /dev/null
+++ b/spec/requests/poll_votes_spec.rb
@@ -0,0 +1,53 @@
+require "rails_helper"
+
+RSpec.describe "PollVotes", type: :request do
+ let(:user) { create(:user) }
+ let(:article) { create(:article) }
+ let(:poll) { create(:poll, article_id: article.id) }
+
+ before { sign_in user }
+
+ describe "GET /poll_votes" do
+ it "returns proper results for poll" do
+ get "/poll_votes/#{poll.id}"
+ expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(0)
+ expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 0])
+ expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id)
+ expect(JSON.parse(response.body)["voted"]).to eq(false)
+ end
+
+ it "returns proper results for poll if voted" do
+ create(:poll_vote, user_id: user.id, poll_option_id: poll.poll_options.first.id, poll_id: poll.id)
+ get "/poll_votes/#{poll.id}"
+ expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(1)
+ expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 1])
+ expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id)
+ expect(JSON.parse(response.body)["voted"]).to eq(true)
+ end
+ end
+
+ describe "POST /poll_votes" do
+ it "votes on behalf of current user" do
+ post "/poll_votes", params: {
+ poll_vote: { poll_option_id: poll.poll_options.first.id }
+ }
+ expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(1)
+ expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 1])
+ expect(JSON.parse(response.body)["poll_id"]).to eq(poll.id)
+ expect(JSON.parse(response.body)["voted"]).to eq(true)
+ expect(user.poll_votes.size).to eq(1)
+ end
+ it "votes on behalf of current user only once" do
+ post "/poll_votes", params: {
+ poll_vote: { poll_option_id: poll.poll_options.first.id }
+ }
+ post "/poll_votes", params: {
+ poll_vote: { poll_option_id: poll.poll_options.first.id }
+ }
+ expect(JSON.parse(response.body)["voting_data"]["votes_count"]).to eq(1)
+ expect(JSON.parse(response.body)["voting_data"]["votes_distribution"]).to include([poll.poll_options.first.id, 1])
+ expect(JSON.parse(response.body)["voted"]).to eq(true)
+ expect(user.poll_votes.size).to eq(1)
+ end
+ end
+end