Merge pull request #1903 from vikhyat/poll-plugin

Add poll plugin
This commit is contained in:
Robin Ward 2014-02-19 13:15:43 -05:00
commit 634b769cda
11 changed files with 539 additions and 1 deletions

View File

@ -425,7 +425,8 @@ Discourse.Composer = Discourse.Model.extend({
this.set('composeState', CLOSED);
return Ember.Deferred.promise(function(promise) {
post.save(function() {
post.save(function(result) {
post.updateFromPost(result);
composer.clearState();
}, function(error) {
var response = $.parseJSON(error.responseText);

View File

@ -0,0 +1,27 @@
<table>
{{#each poll.options}}
<tr {{bind-attr class=checked:active}} {{action selectOption option}}>
<td class="radio"><input type="radio" name="poll" {{bind-attr checked=checked disabled=controller.loading}}></td>
<td class="option">
<div class="option">
{{option}}
</div>
{{#if controller.showResults}}
<div class="result">{{i18n poll.voteCount count=votes}}</div>
{{/if}}
</td>
</tr>
{{/each}}
</table>
<button {{action toggleShowResults}}>
{{#if showResults}}
{{i18n poll.results.hide}}
{{else}}
{{i18n poll.results.show}}
{{/if}}
</button>
{{#if loading}}
<i class="fa fa-spin fa-spinner"></i>
{{/if}}

View File

@ -0,0 +1,9 @@
Discourse.Dialect.inlineBetween({
start: '[poll]',
stop: '[/poll]',
rawContents: true,
emitter: function(contents) {
var list = Discourse.Dialect.cook(contents, {});
return ['div', {class: 'poll-ui'}, list];
}
});

View File

@ -0,0 +1,110 @@
var Poll = Discourse.Model.extend({
post: null,
options: [],
postObserver: function() {
this.updateOptionsFromJson(this.get('post.poll_details'));
}.observes('post.poll_details'),
updateOptionsFromJson: function(json) {
var selectedOption = json["selected"];
var options = [];
Object.keys(json["options"]).forEach(function(option) {
options.push(Ember.Object.create({
option: option,
votes: json["options"][option],
checked: (option == selectedOption)
}));
});
this.set('options', options);
},
saveVote: function(option) {
this.get('options').forEach(function(opt) {
opt.set('checked', opt.get('option') == option);
});
return Discourse.ajax("/poll", {
type: "PUT",
data: {post_id: this.get('post.id'), option: option}
}).then(function(newJSON) {
this.updateOptionsFromJson(newJSON);
}.bind(this));
}
});
var PollController = Discourse.Controller.extend({
poll: null,
showResults: false,
actions: {
selectOption: function(option) {
if (!this.get('currentUser.id')) {
this.get('postController').send('showLogin');
return;
}
this.set('loading', true);
this.get('poll').saveVote(option).then(function() {
this.set('loading', false);
this.set('showResults', true);
}.bind(this));
},
toggleShowResults: function() {
this.set('showResults', !this.get('showResults'));
}
}
});
var PollView = Ember.View.extend({
templateName: "poll",
classNames: ['poll-ui'],
replaceElement: function(target) {
this._insertElementLater(function() {
target.replaceWith(this.$());
});
}
});
function initializePollView(self) {
var post = self.get('post');
var pollDetails = post.get('poll_details');
var poll = Poll.create({post: post});
poll.updateOptionsFromJson(pollDetails);
var pollController = PollController.create({
poll: poll,
showResults: pollDetails["selected"],
postController: self.get('controller')
});
var pollView = self.createChildView(PollView, {
controller: pollController
});
return pollView;
}
Discourse.PostView.reopen({
createPollUI: function($post) {
var post = this.get('post');
if (!post.get('poll_details')) {
return;
}
var view = initializePollView(this);
view.replaceElement($post.find(".poll-ui:first"));
this.set('pollView', view);
}.on('postViewInserted'),
clearPollView: function() {
if (this.get('pollView')) {
this.get('pollView').destroy();
}
}.on('willClearRender')
});

View File

@ -0,0 +1,17 @@
# encoding: utf-8
# This file contains content for the client portion of Discourse, sent out
# to the Javascript app.
#
# To validate this YAML file after you change it, please paste it into
# http://yamllint.com/
en:
js:
poll:
voteCount:
one: "1 vote"
other: "%{count} votes"
results:
show: Show Results
hide: Hide Results

View File

@ -0,0 +1,11 @@
# encoding: utf-8
# This file contains content for the server portion of Discourse used by Ruby
#
# To validate this YAML file after you change it, please paste it into
# http://yamllint.com/
en:
poll:
must_contain_poll_options: "must contain a list of poll options"
cannot_have_modified_options: "cannot have modified poll options after 5 minutes"
prefix: "Poll:"

159
plugins/poll/plugin.rb Normal file
View File

@ -0,0 +1,159 @@
# name: poll
# about: adds poll support to Discourse
# version: 0.1
# authors: Vikhyat Korrapati
load File.expand_path("../poll.rb", __FILE__)
# Without this line we can't lookup the constant inside the after_initialize blocks,
# probably because all of this is instance_eval'd inside an instance of
# Plugin::Instance.
PollPlugin = PollPlugin
after_initialize do
# Rails Engine for accepting votes.
module PollPlugin
class Engine < ::Rails::Engine
engine_name "poll_plugin"
isolate_namespace PollPlugin
end
class PollController < ActionController::Base
include CurrentUser
def vote
if current_user.nil?
render status: :forbidden, json: false
return
end
if params[:post_id].nil? or params[:option].nil?
render status: 400, json: false
return
end
post = Post.find(params[:post_id])
poll = PollPlugin::Poll.new(post)
unless poll.is_poll?
render status: 400, json: false
return
end
options = poll.details
unless options.keys.include? params[:option]
render status: 400, json: false
return
end
poll.set_vote!(current_user, params[:option])
render json: poll.serialize(current_user)
end
end
end
PollPlugin::Engine.routes.draw do
put '/' => 'poll#vote'
end
Discourse::Application.routes.append do
mount ::PollPlugin::Engine, at: '/poll'
end
# Starting a topic title with "Poll:" will create a poll topic. If the title
# starts with "poll:" but the first post doesn't contain a list of options in
# it we need to raise an error.
# Need to add an error when:
# * there is no list of options.
Post.class_eval do
validate :poll_options
def poll_options
poll = PollPlugin::Poll.new(self)
return unless poll.is_poll?
if poll.options.length == 0
self.errors.add(:raw, I18n.t('poll.must_contain_poll_options'))
end
if self.created_at and self.created_at < 5.minutes.ago and poll.options.sort != poll.details.keys.sort
self.errors.add(:raw, I18n.t('poll.cannot_have_modified_options'))
end
end
end
# Save the list of options to PluginStore after the post is saved.
Post.class_eval do
after_save :save_poll_options_to_topic_metadata
def save_poll_options_to_topic_metadata
poll = PollPlugin::Poll.new(self)
if poll.is_poll?
details = poll.details || {}
new_options = poll.options
details.each do |key, value|
unless new_options.include? key
details.delete(key)
end
end
new_options.each do |key|
details[key] ||= 0
end
poll.set_details! details
end
end
end
# Add poll details into the post serializer.
PostSerializer.class_eval do
attributes :poll_details
def poll_details
PollPlugin::Poll.new(object).serialize(scope.user)
end
def include_poll_details?
PollPlugin::Poll.new(object).is_poll?
end
end
end
# Poll UI.
register_asset "javascripts/discourse/templates/poll.js.handlebars"
register_asset "javascripts/poll_ui.js"
register_asset "javascripts/poll_bbcode.js", :server_side
register_css <<CSS
.poll-ui table {
margin-bottom: 5px;
}
.poll-ui tr {
cursor: pointer;
}
.poll-ui td.radio input {
margin-left: -10px !important;
}
.poll-ui td {
padding: 4px 8px;
}
.poll-ui td.option .option {
float: left;
}
.poll-ui td.option .result {
float: right;
margin-left: 50px;
}
.poll-ui tr.active {
background-color: #FFFFB3;
}
.poll-ui button {
border: none;
}
CSS

77
plugins/poll/poll.rb Normal file
View File

@ -0,0 +1,77 @@
module ::PollPlugin
class Poll
def initialize(post)
@post = post
end
def is_poll?
if !@post.post_number.nil? and @post.post_number > 1
# Not a new post, and also not the first post.
return false
end
topic = @post.topic
# Topic is not set in a couple of cases in the Discourse test suite.
return false if topic.nil?
if @post.post_number.nil? and topic.highest_post_number > 0
# New post, but not the first post in the topic.
return false
end
topic.title =~ /^#{I18n.t('poll.prefix')}/i
end
def options
cooked = PrettyText.cook(@post.raw, topic_id: @post.topic_id)
poll_div = Nokogiri::HTML(cooked).css(".poll-ui").first
if poll_div
poll_div.css("li").map {|x| x.children.to_s.strip }.uniq
else
[]
end
end
def details
@details ||= ::PluginStore.get("poll", details_key)
end
def set_details!(new_details)
::PluginStore.set("poll", details_key, new_details)
@details = new_details
end
def get_vote(user)
user.nil? ? nil : ::PluginStore.get("poll", vote_key(user))
end
def set_vote!(user, option)
# Get the user's current vote.
vote = get_vote(user)
vote = nil unless details.keys.include? vote
new_details = details.dup
new_details[vote] -= 1 if vote
new_details[option] += 1
::PluginStore.set("poll", vote_key(user), option)
set_details! new_details
end
def serialize(user)
return nil if details.nil?
{options: details, selected: get_vote(user)}
end
private
def details_key
"poll_options_#{@post.id}"
end
def vote_key(user)
"poll_vote_#{@post.id}_#{user.id}"
end
end
end

View File

@ -0,0 +1,51 @@
require 'spec_helper'
describe PollPlugin::PollController, type: :controller do
let(:topic) { create_topic(title: "Poll: Chitoge vs Onodera") }
let(:post) { create_post(topic: topic, raw: "Pick one.\n\n[poll]\n* Chitoge\n* Onodera\n[/poll]") }
let(:user1) { Fabricate(:user) }
let(:user2) { Fabricate(:user) }
it "should return 403 if no user is logged in" do
xhr :put, :vote, post_id: post.id, option: "Chitoge", use_route: :poll
response.should be_forbidden
end
it "should return 400 if post_id or invalid option is not specified" do
log_in_user user1
xhr :put, :vote, use_route: :poll
response.status.should eq(400)
xhr :put, :vote, post_id: post.id, use_route: :poll
response.status.should eq(400)
xhr :put, :vote, option: "Chitoge", use_route: :poll
response.status.should eq(400)
xhr :put, :vote, post_id: post.id, option: "Tsugumi", use_route: :poll
response.status.should eq(400)
end
it "should return 400 if post_id doesn't correspond to a poll post" do
log_in_user user1
post2 = create_post(topic: topic, raw: "Generic reply")
xhr :put, :vote, post_id: post2.id, option: "Chitoge", use_route: :poll
response.status.should eq(400)
end
it "should save votes correctly" do
log_in_user user1
xhr :put, :vote, post_id: post.id, option: "Chitoge", use_route: :poll
PollPlugin::Poll.new(post).get_vote(user1).should eq("Chitoge")
log_in_user user2
xhr :put, :vote, post_id: post.id, option: "Onodera", use_route: :poll
PollPlugin::Poll.new(post).get_vote(user2).should eq("Onodera")
PollPlugin::Poll.new(post).details["Chitoge"].should eq(1)
PollPlugin::Poll.new(post).details["Onodera"].should eq(1)
xhr :put, :vote, post_id: post.id, option: "Chitoge", use_route: :poll
PollPlugin::Poll.new(post).get_vote(user2).should eq("Chitoge")
PollPlugin::Poll.new(post).details["Chitoge"].should eq(2)
PollPlugin::Poll.new(post).details["Onodera"].should eq(0)
end
end

View File

@ -0,0 +1,51 @@
require 'spec_helper'
describe PollPlugin::Poll do
let(:topic) { create_topic(title: "Poll: Chitoge vs Onodera") }
let(:post) { create_post(topic: topic, raw: "Pick one.\n\n[poll]\n* Chitoge\n* Onodera\n[/poll]") }
let(:poll) { PollPlugin::Poll.new(post) }
let(:user) { Fabricate(:user) }
it "should detect poll post correctly" do
expect(poll.is_poll?).to be_true
post2 = create_post(topic: topic, raw: "This is a generic reply.")
expect(PollPlugin::Poll.new(post2).is_poll?).to be_false
post.topic.title = "Not a poll"
expect(poll.is_poll?).to be_false
end
it "should get options correctly" do
expect(poll.options).to eq(["Chitoge", "Onodera"])
end
it "should get details correctly" do
expect(poll.details).to eq({"Chitoge" => 0, "Onodera" => 0})
end
it "should set details correctly" do
poll.set_details!({})
poll.details.should eq({})
PollPlugin::Poll.new(post).details.should eq({})
end
it "should get and set votes correctly" do
poll.get_vote(user).should eq(nil)
poll.set_vote!(user, "Onodera")
poll.get_vote(user).should eq("Onodera")
poll.details["Onodera"].should eq(1)
end
it "should serialize correctly" do
poll.serialize(user).should eq({options: poll.details, selected: nil})
poll.set_vote!(user, "Onodera")
poll.serialize(user).should eq({options: poll.details, selected: "Onodera"})
poll.serialize(nil).should eq({options: poll.details, selected: nil})
end
it "should serialize to nil if there are no poll options" do
topic = create_topic(title: "This is not a poll topic")
post = create_post(topic: topic, raw: "no options in the content")
poll = PollPlugin::Poll.new(post)
poll.serialize(user).should eq(nil)
end
end

View File

@ -0,0 +1,25 @@
require 'spec_helper'
require 'post_creator'
describe PostCreator do
let(:user) { Fabricate(:user) }
context "poll topic" do
it "cannot be created without a list of options" do
post = PostCreator.create(user, {title: "Poll: This is a poll", raw: "body does not contain a list"})
post.errors[:raw].should be_present
end
it "cannot have options changed after 5 minutes" do
post = PostCreator.create(user, {title: "Poll: This is a poll", raw: "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]"})
post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n[/poll]"
post.valid?.should be_true
post.save
Timecop.freeze(Time.now + 6.minutes) do
post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]"
post.valid?.should be_false
post.errors[:raw].should be_present
end
end
end
end