From 74ab14de1982c8bfb4936881c033576d64103f91 Mon Sep 17 00:00:00 2001 From: Vikhyat Korrapati Date: Wed, 5 Feb 2014 08:23:09 +0530 Subject: [PATCH] Add poll plugin. --- .../javascripts/discourse/models/composer.js | 3 +- .../discourse/templates/poll.js.handlebars | 27 +++ .../poll/assets/javascripts/poll_bbcode.js | 9 + plugins/poll/assets/javascripts/poll_ui.js | 110 ++++++++++++ plugins/poll/config/locales/client.en.yml | 17 ++ plugins/poll/config/locales/server.en.yml | 11 ++ plugins/poll/plugin.rb | 159 ++++++++++++++++++ plugins/poll/poll.rb | 77 +++++++++ .../spec/poll_plugin/poll_controller_spec.rb | 51 ++++++ plugins/poll/spec/poll_plugin/poll_spec.rb | 51 ++++++ plugins/poll/spec/post_creator_spec.rb | 25 +++ 11 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 plugins/poll/assets/javascripts/discourse/templates/poll.js.handlebars create mode 100644 plugins/poll/assets/javascripts/poll_bbcode.js create mode 100644 plugins/poll/assets/javascripts/poll_ui.js create mode 100644 plugins/poll/config/locales/client.en.yml create mode 100644 plugins/poll/config/locales/server.en.yml create mode 100644 plugins/poll/plugin.rb create mode 100644 plugins/poll/poll.rb create mode 100644 plugins/poll/spec/poll_plugin/poll_controller_spec.rb create mode 100644 plugins/poll/spec/poll_plugin/poll_spec.rb create mode 100644 plugins/poll/spec/post_creator_spec.rb diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index 234328603..984ad5880 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 000000000..8e2a5f446 --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/templates/poll.js.handlebars @@ -0,0 +1,27 @@ + + {{#each poll.options}} + + + + + {{/each}} +
+
+ {{option}} +
+ {{#if controller.showResults}} +
{{i18n poll.voteCount count=votes}}
+ {{/if}} +
+ + + +{{#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 000000000..f8d7c845a --- /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 000000000..cd0d4ef59 --- /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 000000000..0d9ed7c52 --- /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 000000000..a270072f3 --- /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 000000000..a959d1bab --- /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 000000000..132b50d5b --- /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 000000000..7ce19544c --- /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 000000000..09db32ce2 --- /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