diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js
index 2343286033a..984ad588027 100644
--- a/app/assets/javascripts/discourse/models/composer.js
+++ b/app/assets/javascripts/discourse/models/composer.js
@@ -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);
diff --git a/plugins/poll/assets/javascripts/discourse/templates/poll.js.handlebars b/plugins/poll/assets/javascripts/discourse/templates/poll.js.handlebars
new file mode 100644
index 00000000000..8e2a5f446c7
--- /dev/null
+++ b/plugins/poll/assets/javascripts/discourse/templates/poll.js.handlebars
@@ -0,0 +1,27 @@
+
+
+
+
+{{#if loading}}
+
+{{/if}}
diff --git a/plugins/poll/assets/javascripts/poll_bbcode.js b/plugins/poll/assets/javascripts/poll_bbcode.js
new file mode 100644
index 00000000000..f8d7c845aa0
--- /dev/null
+++ b/plugins/poll/assets/javascripts/poll_bbcode.js
@@ -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];
+ }
+});
diff --git a/plugins/poll/assets/javascripts/poll_ui.js b/plugins/poll/assets/javascripts/poll_ui.js
new file mode 100644
index 00000000000..cd0d4ef59be
--- /dev/null
+++ b/plugins/poll/assets/javascripts/poll_ui.js
@@ -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')
+});
diff --git a/plugins/poll/config/locales/client.en.yml b/plugins/poll/config/locales/client.en.yml
new file mode 100644
index 00000000000..0d9ed7c52bc
--- /dev/null
+++ b/plugins/poll/config/locales/client.en.yml
@@ -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
diff --git a/plugins/poll/config/locales/server.en.yml b/plugins/poll/config/locales/server.en.yml
new file mode 100644
index 00000000000..a270072f30b
--- /dev/null
+++ b/plugins/poll/config/locales/server.en.yml
@@ -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:"
diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb
new file mode 100644
index 00000000000..a959d1bab15
--- /dev/null
+++ b/plugins/poll/plugin.rb
@@ -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 < 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
diff --git a/plugins/poll/spec/poll_plugin/poll_controller_spec.rb b/plugins/poll/spec/poll_plugin/poll_controller_spec.rb
new file mode 100644
index 00000000000..132b50d5bcd
--- /dev/null
+++ b/plugins/poll/spec/poll_plugin/poll_controller_spec.rb
@@ -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
diff --git a/plugins/poll/spec/poll_plugin/poll_spec.rb b/plugins/poll/spec/poll_plugin/poll_spec.rb
new file mode 100644
index 00000000000..7ce19544c23
--- /dev/null
+++ b/plugins/poll/spec/poll_plugin/poll_spec.rb
@@ -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
diff --git a/plugins/poll/spec/post_creator_spec.rb b/plugins/poll/spec/post_creator_spec.rb
new file mode 100644
index 00000000000..09db32ce2df
--- /dev/null
+++ b/plugins/poll/spec/post_creator_spec.rb
@@ -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