Merge pull request #4861 from JannikStreek/feature/3676-conduct_polls

Feature/3676 Conduct Polls
This commit is contained in:
Jonne Haß 2014-03-31 20:02:29 +02:00
commit faad980df0
45 changed files with 1074 additions and 30 deletions

3
.gitignore vendored
View file

@ -65,3 +65,6 @@ dump.rdb
#Rubinius's JIT
*.rbc
#IDE
diaspora.iml

View file

@ -40,6 +40,7 @@
* Added comment count to statistic to enable calculations of posts/comments ratios [#4799](https://github.com/diaspora/diaspora/pull/4799)
* Add filters to notifications controller [#4814](https://github.com/diaspora/diaspora/pull/4814)
* Activate hovercards in SPV and conversations [#4870](https://github.com/diaspora/diaspora/pull/4870)
* Added possibility to conduct polls [#4861](https://github.com/diaspora/diaspora/pull/4861)
# 0.3.0.3
@ -58,6 +59,7 @@
## Bug fixes
* Fix regression caused by using after_commit with nested '#save' which lead to an infinite recursion [#4715](https://github.com/diaspora/diaspora/issues/4715)
* Save textarea value before rendering comments when clicked 'show more...' [#4514](https://github.com/diaspora/diaspora/issues/4514)
# 0.3.0.0
## Pod statistics

View file

@ -0,0 +1,5 @@
app.models.PollParticipation = Backbone.Model.extend({
url : function(){
"/poll_participations"
}
});

View file

@ -13,7 +13,8 @@ app.models.StatusMessage = app.models.Post.extend({
status_message : _.clone(this.attributes),
aspect_ids : this.get("aspect_ids"),
photos : this.photos && this.photos.pluck("id"),
services : this.get("services")
services : this.get("services"),
poll : this.get("poll")
}
}
});

View file

@ -0,0 +1,94 @@
app.views.Poll = app.views.Base.extend({
templateName : "poll",
events : {
"click .submit" : "vote",
"click .toggle_result" : "toggleResult"
},
initialize : function(options) {
this.poll = this.model.attributes.poll;
this.progressBarFactor = 3;
this.toggleMode = 0;
},
postRenderTemplate : function() {
if(this.poll) {
this.setProgressBar();
}
},
removeForm : function() {
var cnt = this.$("form").contents();
this.$("form").replaceWith(cnt);
this.$('input').remove();
this.$('submit').remove();
this.$('.toggle_result_wrapper').remove();
},
setProgressBar : function() {
var answers = this.poll.poll_answers;
for(index = 0; index < answers.length; ++index) {
var percentage = 0;
if(this.poll.participation_count != 0) {
percentage = Math.round(answers[index].vote_count / this.poll.participation_count * 100);
}
var progressBar = this.$(".poll_progress_bar[data-answerid="+answers[index].id+"]");
progressBar.parent().next().html(" - " + percentage + "%");
var width = percentage * this.progressBarFactor;
progressBar.css("width", width + "px");
}
},
toggleResult : function(e) {
this.$('.poll_progress_bar_wrapper').toggle();
this.$('.percentage').toggle();
if(this.toggleMode == 0) {
this.$('.toggle_result').html(Diaspora.I18n.t("poll.close_result"));
this.toggleMode = 1;
}else{
this.$('.toggle_result').html(Diaspora.I18n.t("poll.show_result"));
this.toggleMode = 0;
}
return false;
},
refreshResult : function(answerId) {
this.updateCounter(answerId);
this.setProgressBar();
},
updateCounter : function(answerId) {
this.poll.participation_count++;
this.$('.poll_statistic').html(Diaspora.I18n.t("poll.count", {"count" : this.poll.participation_count}));
var answers = this.poll.poll_answers;
for(index = 0; index < answers.length; ++index) {
if(answers[index].id == answerId) {
answers[index].vote_count++;
return;
}
}
},
vote : function(evt){
var result = parseInt($(evt.target).parent().find("input[name=vote]:checked").val());
var pollParticipation = new app.models.PollParticipation();
var parent = this;
pollParticipation.save({
"poll_answer_id" : result,
"poll_id" : this.poll.poll_id
},{
url : "/posts/"+this.poll.post_id+"/poll_participations",
success : function(model, response) {
parent.removeForm();
parent.refreshResult(result);
if(parent.toggleMode == 0) {
parent.toggleResult(null);
}
}
});
return false;
}
});

View file

@ -23,12 +23,16 @@ app.views.Publisher = Backbone.View.extend({
"click .post_preview_button" : "createPostPreview",
"textchange #status_message_fake_text": "handleTextchange",
"click #locator" : "showLocation",
"click #poll_creator" : "showPollCreator",
"click #add_poll_answer" : "addPollAnswer",
"click .remove_poll_answer" : "removePollAnswer",
"click #hide_location" : "destroyLocation",
"keypress #location_address" : "avoidEnter"
},
initialize : function(opts){
this.standalone = opts ? opts.standalone : false;
this.option_counter = 1;
// init shortcut references to the various elements
this.el_input = this.$('#status_message_fake_text');
@ -37,6 +41,8 @@ app.views.Publisher = Backbone.View.extend({
this.el_submit = this.$('input[type=submit], button#submit');
this.el_preview = this.$('button.post_preview_button');
this.el_photozone = this.$('#photodropzone');
this.el_poll_creator = this.$('#poll_creator_wrapper');
this.el_poll_answer = this.$('#poll_creator_wrapper .poll_answer');
// init mentions plugin
Mentions.initialize(this.el_input);
@ -69,7 +75,7 @@ app.views.Publisher = Backbone.View.extend({
});
this.initSubviews();
this.addPollAnswer();
return this;
},
@ -136,7 +142,9 @@ app.views.Publisher = Backbone.View.extend({
"photos" : serializedForm["photos[]"],
"services" : serializedForm["services[]"],
"location_address" : $("#location_address").val(),
"location_coords" : serializedForm["location[coords]"]
"location_coords" : serializedForm["location[coords]"],
"poll_question" : serializedForm["poll_question"],
"poll_answers" : serializedForm["poll_answers[]"]
}, {
url : "/status_messages",
success : function() {
@ -171,6 +179,36 @@ app.views.Publisher = Backbone.View.extend({
}
},
showPollCreator: function(){
this.el_poll_creator.toggle();
},
addPollAnswer: function(){
if($(".poll_answer").size() == 1) {
$(".remove_poll_answer").css("visibility","visible");
}
this.option_counter++;
var clone = this.el_poll_answer.clone();
var answer = clone.find('.poll_answer_input');
answer.val("");
var placeholder = answer.attr("placeholder");
var expression = /[^0-9]+/;
answer.attr("placeholder", expression.exec(placeholder) + this.option_counter);
$('#poll_creator_wrapper .poll_answer').last().after(clone);
},
removePollAnswer: function(evt){
$(evt.currentTarget).parent().remove();
if($(".poll_answer").size() == 1) {
$(".remove_poll_answer").css("visibility","hidden");;
}
return false;
},
// avoid submitting form when pressing Enter key
avoidEnter: function(evt){
if(evt.keyCode == 13)
@ -295,6 +333,9 @@ app.views.Publisher = Backbone.View.extend({
// clear location
this.destroyLocation();
// clear poll form
this.clearPollForm();
// force textchange plugin to update lastValue
this.el_input.data('lastValue', '');
this.el_hiddenInput.data('lastValue', '');
@ -302,6 +343,11 @@ app.views.Publisher = Backbone.View.extend({
return this;
},
clearPollForm : function(){
this.$('#poll_question').val('');
this.$('.poll_answer_input').val('');
},
tryClose : function(){
// if it is not submittable, close it.
if( !this._submittable() ){
@ -323,7 +369,7 @@ app.views.Publisher = Backbone.View.extend({
$(this.el).addClass("closed");
this.el_wrapper.removeClass("active");
this.el_input.css('height', '');
this.el_poll_creator.hide();
return this;
},

View file

@ -9,6 +9,7 @@ app.views.StreamPost = app.views.Post.extend({
".post-content" : "postContentView",
".oembed" : "oEmbedView",
".opengraph" : "openGraphView",
".poll" : "pollView",
".status-message-location" : "postLocationStreamView"
},
@ -31,6 +32,7 @@ app.views.StreamPost = app.views.Post.extend({
this.commentStreamView = new app.views.CommentStream({model : this.model});
this.oEmbedView = new app.views.OEmbed({model : this.model});
this.openGraphView = new app.views.OpenGraph({model : this.model});
this.pollView = new app.views.Poll({model : this.model});
},

View file

@ -10,6 +10,7 @@
@import 'header'
@import 'footer'
@import 'opengraph'
@import 'poll'
@import 'help'
@import 'profile'
@import 'publisher_blueprint'

View file

@ -0,0 +1,45 @@
.poll_form {
display: block;
margin: 10px 0px 10px 0px;
border-top: solid 1px $border-grey;
border-bottom: solid 1px $border-grey;
padding: 10px 0px 5px 0px;
overflow: hidden;
width: 100%;
}
.poll_form input[type="radio"] {
display:inline !important;
}
.poll_result {
width:100%px;
}
.poll_progress_bar {
position:absolute;
width:0px;
height:15px;
top:-12px;
z-index:-1;
background-color:$background-grey;
}
.poll_statistic{
float:right;
}
.poll_progress_bar_wrapper {
position: relative;
width: 0;
height: 0;
display:inline-block;
}
.poll_answer_entry{
width:100%;
}
.percentage {
display:inline;
}

View file

@ -82,6 +82,16 @@
&.with_attachments .row-fluid#photodropzone_container {
border-top: 1px dashed $border-grey;
}
#poll_creator_wrapper {
display:none;
border-top: 1px dashed $border-grey;
padding:4px 6px 4px 6px;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
}
&.with_location .row-fluid#location_container {
height: 30px;
#hide_location { display: none !important; }
@ -162,6 +172,7 @@
margin-right: 5px;
#file-upload,
#locator,
#poll_creator,
#hide_location {
text-decoration: none !important;
font-size: 16px;

View file

@ -309,8 +309,75 @@
}
}
}
#poll_creator {
bottom: 1px !important;
display: inline-block;
margin: 0;
cursor: pointer;
position: absolute !important;
right: 55px;
i {
@include opacity(0.4);
}
&:hover {
color: $text-dark-grey;
cursor: pointer;
i {
@include opacity(1);
}
}
}
.btn {
height: 19px;
width: 19px;
}
}
#poll_creator_wrapper {
display:none;
border: 1px solid $border-dark-grey;
padding:5px;
margin-top:1em;
@include border-radius(2px);
}
.remove_poll_answer {
visibility:hidden;
float:right;
display: table-cell;
.icons-deletelabel {
height: 14px;
width: 14px;
margin-top:5px;
}
}
.poll_answer_input {
width:100%;
}
#add_poll_answer_wrapper {
padding:5px 0 5px 0;
display:block;
}
#poll_question_wrapper {
}
#poll_question {
width: 100%;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
}
.poll_answer {
display: table;
width: 100%;
}
.poll_answer_input_wrapper {
display: table-cell;
}

View file

@ -0,0 +1,32 @@
{{#if poll}}
<div class="poll_form">
<p class="poll_statistic">{{t "poll.count" count=poll.participation_count}}</p>
<strong>{{poll.question}}</strong><br/>
{{#unless already_participated_in_poll}}
<form action="/posts/{{poll.post_id}}/poll_participations" method="POST">
{{#poll.poll_answers}}
<input type="radio" name="vote" value="{{id}}"/>
<div class="poll_progress_bar_wrapper" style="display:none;">
<div class="poll_progress_bar" data-answerid="{{id}}"></div>
</div>
{{answer}}
<p class="percentage" style="display:none;"></p>
<br/>
{{/poll.poll_answers}}
<input type="submit" class="button submit" style="float:right;" value="{{t "poll.vote"}}"/>
</form>
<p class="toggle_result_wrapper">
<br/><a href="#" class="toggle_result">{{t "poll.show_result"}}</a><br/>
</p>
{{else}}
{{#poll.poll_answers}}
<div class="poll_progress_bar_wrapper">
<div class="poll_progress_bar" data-answerid="{{id}}"></div>
</div>
{{answer}}
<p class="percentage"></p>
<br/>
{{/poll.poll_answers}}
{{/unless}}
</div>
{{/if}}

View file

@ -18,4 +18,5 @@
{{{text}}}
<div class="oembed"></div>
<div class="opengraph"></div>
<div class="poll"></div>
</div>

View file

@ -0,0 +1,28 @@
class PollParticipationsController < ApplicationController
include ApplicationHelper
before_filter :authenticate_user!
def create
answer = PollAnswer.find(params[:poll_answer_id])
poll_participation = current_user.participate_in_poll!(target, answer) if target
respond_to do |format|
format.html { redirect_to :back }
format.mobile { redirect_to stream_path }
format.json { render json: poll_participation, :status => 201 }
end
rescue ActiveRecord::RecordInvalid
respond_to do |format|
format.html { redirect_to :back }
format.mobile { redirect_to stream_path }
format.json { render :nothing => true, :status => 403 }
end
end
private
def target
@target ||= if params[:post_id]
current_user.find_visible_shareable_by_id(Post, params[:post_id]) || raise(ActiveRecord::RecordNotFound.new)
end
end
end

View file

@ -49,6 +49,14 @@ class StatusMessagesController < ApplicationController
@status_message = current_user.build_post(:status_message, params[:status_message])
@status_message.build_location(:address => params[:location_address], :coordinates => params[:location_coords]) if params[:location_address].present?
if params[:poll_question].present?
@status_message.build_poll(:question => params[:poll_question])
[*params[:poll_answers]].each do |poll_answer|
@status_message.poll.poll_answers.build(:answer => poll_answer)
end
end
@status_message.attach_photos_by_ids(params[:photos])
if @status_message.save

40
app/models/poll.rb Normal file
View file

@ -0,0 +1,40 @@
class Poll < ActiveRecord::Base
include Diaspora::Federated::Base
include Diaspora::Guid
attr_accessible :question, :poll_answers
belongs_to :status_message
has_many :poll_answers
has_many :poll_participations
xml_attr :question
xml_attr :poll_answers, :as => [PollAnswer]
#forward some requests to status message, because a poll is just attached to a status message and is not sharable itself
delegate :author, :author_id, :public?, :subscribers, to: :status_message
validate :enough_poll_answers
self.include_root_in_json = false
def enough_poll_answers
errors.add(:poll_answers, I18n.t("activerecord.errors.models.poll.attributes.poll_answers.not_enough_poll_answers")) if poll_answers.size < 2
end
def as_json(options={})
{
:poll_id => self.id,
:post_id => self.status_message.id,
:question => self.question,
:poll_answers => self.poll_answers,
:participation_count => self.participation_count,
}
end
def participation_count
poll_answers.sum("vote_count")
end
def already_participated?(user)
poll_participations.where(:author_id => user.person.id).present?
end
end

13
app/models/poll_answer.rb Normal file
View file

@ -0,0 +1,13 @@
class PollAnswer < ActiveRecord::Base
include Diaspora::Federated::Base
include Diaspora::Guid
belongs_to :poll
has_many :poll_participations
xml_attr :answer
self.include_root_in_json = false
end

View file

@ -0,0 +1,66 @@
class PollParticipation < ActiveRecord::Base
include Diaspora::Federated::Base
include Diaspora::Guid
include Diaspora::Relayable
belongs_to :poll
belongs_to :poll_answer, counter_cache: :vote_count
belongs_to :author, :class_name => 'Person', :foreign_key => :author_id
xml_attr :diaspora_handle
xml_attr :poll_answer_guid
xml_convention :underscore
validate :not_already_participated
def parent_class
Poll
end
def parent
self.poll
end
def poll_answer_guid
poll_answer.guid
end
def poll_answer_guid= new_poll_answer_guid
self.poll_answer = PollAnswer.where(:guid => new_poll_answer_guid).first
end
def parent= parent
self.poll = parent
end
def diaspora_handle
self.author.diaspora_handle
end
def diaspora_handle= nh
self.author = Webfinger.new(nh).fetch
end
def not_already_participated
return if poll.nil?
other_participations = PollParticipation.where(author_id: self.author.id, poll_id: self.poll.id).to_a-[self]
if other_participations.present?
self.errors.add(:poll, I18n.t("activerecord.errors.models.poll_participations.attributes.poll.already_participated"))
end
end
class Generator < Federated::Generator
def self.federated_class
PollParticipation
end
def initialize(person, target, poll_answer)
@poll_answer = poll_answer
super(person, target)
end
def relayable_options
{:poll => @target.poll, :poll_answer => @poll_answer}
end
end
end

View file

@ -72,6 +72,9 @@ class Post < ActiveRecord::Base
def address
end
def poll
end
def self.excluding_blocks(user)
people = user.blocks.map{|b| b.person_id}
scope = scoped

View file

@ -20,10 +20,13 @@ class StatusMessage < Post
xml_attr :raw_message
xml_attr :photos, :as => [Photo]
xml_attr :location, :as => Location
xml_attr :poll, :as => Poll
has_many :photos, :dependent => :destroy, :foreign_key => :status_message_guid, :primary_key => :guid
has_one :location
has_one :poll, autosave: true
# a StatusMessage is federated before its photos are so presence_of_content() fails erroneously if no text is present
# therefore, we put the validation in a before_destory callback instead of a validation

View file

@ -13,6 +13,11 @@ module User::SocialActions
Like::Generator.new(self, target).create!(opts)
end
def participate_in_poll!(target, answer, opts={})
find_or_create_participation!(target)
PollParticipation::Generator.new(self, target, answer).create!(opts)
end
def reshare!(target, opts={})
find_or_create_participation!(target)
reshare = build_post(:reshare, :root_guid => target.guid)

View file

@ -35,6 +35,8 @@ class PostPresenter
:root => root,
:title => title,
:address => @post.address,
:poll => @post.poll(),
:already_participated_in_poll => already_participated_in_poll,
:interactions => {
:likes => [user_like].compact,
@ -72,6 +74,14 @@ class PostPresenter
@current_user.present?
end
private
def already_participated_in_poll
if @post.poll
@post.poll.already_participated?(current_user)
end
end
end
class PostInteractionPresenter

View file

@ -28,12 +28,26 @@
%span#publisher-images
%span.markdownIndications
!= t('shared.publisher.formatWithMarkdown', markdown_link: link_to(t('help.markdown'), 'https://diasporafoundation.org/formatting', target: :blank))
#poll_creator.btn{:title => t('shared.publisher.poll.add_a_poll')}
%i.entypo.bar-graph{:class => "publisher_image"}
#locator.btn{:title => t('shared.publisher.get_location')}
= image_tag 'icons/marker.png', :alt => t('shared.publisher.get_location').titleize, :class => 'publisher_image'
#file-upload.btn{:title => t('shared.publisher.upload_photos')}
= image_tag 'icons/camera.png', :alt => t('shared.publisher.upload_photos').titleize, :class => 'publisher_image'
= hidden_field :location, :coords
#location_container
#poll_creator_wrapper
#poll_question_wrapper
%input{:id => 'poll_question', :placeholder => t('shared.publisher.poll.question'), :name => 'poll_question'}
.poll_answer
%span{:class => 'poll_answer_input_wrapper'}
%input{:class => 'poll_answer_input', :placeholder => t('shared.publisher.poll.option'), :name => 'poll_answers[]'}
%a{:class => 'remove_poll_answer', :title => t('shared.publisher.poll.remove_poll_answer')}
.icons-deletelabel
#add_poll_answer_wrapper
#add_poll_answer{:class => 'button creation'}
= t('shared.publisher.poll.add_poll_answer')
- if publisher_public
= hidden_field_tag 'aspect_ids[]', "public"

View file

@ -22,8 +22,20 @@
%ul#photodropzone
.row-fluid#location_container
= hidden_field :location, :coords
.row-fluid#poll_creator_wrapper
#poll_question_wrapper{:class => "input-block-level"}
%input{:id => 'poll_question', :placeholder => t('shared.publisher.poll.question'), :name => 'poll_question', :class=> "form-control"}
.poll_answer
%input{:class => 'form-control poll_answer_input', :placeholder => t('shared.publisher.poll.option'), :name => 'poll_answers[]'}
.remove_poll_answer.btn.btn-link{:title => t('shared.publisher.poll.remove_poll_answer')}
%i.entypo.trash
#add_poll_answer_wrapper
#add_poll_answer{:class => 'btn btn-default'}
= t('shared.publisher.poll.add_poll_answer')
.row-fluid#button_container
#publisher-images.pull-right
#poll_creator.btn.btn-link{:title => t('shared.publisher.poll.add_a_poll')}
%i.entypo.bar-graph
#file-upload.btn.btn-link{:title => t('shared.publisher.upload_photos')}
%i.entypo.camera.publisher_image
#locator.btn.btn-link{:title => t('shared.publisher.get_location')}

View file

@ -26,3 +26,11 @@
attributes:
from_id:
taken: "is a duplicate of a pre-existing request."
poll:
attributes:
poll_answers:
not_enough_poll_answers: "Not enough poll options provided."
poll_participation:
attributes:
poll:
already_participated: "You've already participated in this poll!"

View file

@ -26,3 +26,11 @@
attributes:
from_id:
taken: "is a duplicate of a pre-existing request."
poll:
attributes:
poll_answers:
not_enough_poll_answers: "Not enough poll options provided."
poll_participation:
attributes:
poll:
already_participated: "You've already participated in this poll!"

View file

@ -26,4 +26,11 @@
attributes:
from_id:
taken: "is a duplicate of a pre-existing request."
poll:
attributes:
poll_answers:
not_enough_poll_answers: "Not enough poll options provided."
poll_participation:
attributes:
poll:
already_participated: "You've already participated in this poll!"

View file

@ -77,6 +77,14 @@ en:
attributes:
root_guid:
taken: "That good, huh? You've already reshared that post!"
poll:
attributes:
poll_answers:
not_enough_poll_answers: "Not enough poll options provided."
poll_participation:
attributes:
poll:
already_participated: "You've already participated in this poll!"
error_messages:
helper:
invalid_fields: "Invalid Fields"
@ -1037,6 +1045,12 @@ en:
hello: "Hey everyone, I'm #%{new_user_tag}. "
i_like: "I'm interested in %{tags}. "
invited_by: "Thanks for the invite, "
poll:
remove_poll_answer: "Remove option"
add_poll_answer: "Add option"
add_a_poll: "Add a poll"
question: "Question"
option: "Option 1"
add_contact:
enter_a_diaspora_username: "Enter a diaspora* username:"
your_diaspora_username_is: "Your diaspora* username is: %{diaspora_handle}"

View file

@ -172,3 +172,12 @@ en:
reshared: "Reshared"
comment: "Comment"
home: "HOME"
poll:
vote: "Vote"
result: "Result"
count:
one: "1 vote so far"
other: "<%=count%> votes so far"
show_result: "Show result"
close_result: "Hide result"

View file

@ -31,11 +31,15 @@ Diaspora::Application.routes.draw do
get :interactions
end
resources :poll_participations, :only => [:create]
resources :likes, :only => [:create, :destroy, :index ]
resources :participations, :only => [:create, :destroy, :index]
resources :comments, :only => [:new, :create, :destroy, :index]
end
get 'p/:id' => 'posts#show', :as => 'short_post'
get 'posts/:id/iframe' => 'posts#iframe', :as => 'iframe'

View file

@ -0,0 +1,38 @@
class CreatePolls < ActiveRecord::Migration
def up
create_table :polls do |t|
t.string :question, :null => false
t.belongs_to :status_message, :null => false
t.boolean :status
t.string :guid
t.timestamps
end
add_index :polls, :status_message_id
create_table :poll_answers do |t|
t.string :answer, :null => false
t.belongs_to :poll, :null => false
t.string :guid
t.integer :vote_count, :default => 0
end
add_index :poll_answers, :poll_id
create_table :poll_participations do |t|
t.belongs_to :poll_answer, :null => false
t.belongs_to :author, :null => false
t.belongs_to :poll, :null => false
t.string :guid
t.text :author_signature
t.text :parent_author_signature
t.timestamps
end
add_index :poll_participations, :poll_id
end
def down
drop_table :polls
drop_table :poll_answers
drop_table :poll_participations
end
end

View file

@ -11,7 +11,7 @@
#
# It's strongly recommended to check this file into your version control system.
ActiveRecord::Schema.define(:version => 20140222162826) do
ActiveRecord::Schema.define(:version => 20140308154022) do
create_table "account_deletions", :force => true do |t|
t.string "diaspora_handle"
@ -283,6 +283,39 @@ ActiveRecord::Schema.define(:version => 20140222162826) do
t.datetime "updated_at", :null => false
end
create_table "poll_answers", :force => true do |t|
t.string "answer", :null => false
t.integer "poll_id", :null => false
t.string "guid"
t.integer "vote_count", :default => 0
end
add_index "poll_answers", ["poll_id"], :name => "index_poll_answers_on_poll_id"
create_table "poll_participations", :force => true do |t|
t.integer "poll_answer_id", :null => false
t.integer "author_id", :null => false
t.integer "poll_id", :null => false
t.string "guid"
t.text "author_signature"
t.text "parent_author_signature"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
add_index "poll_participations", ["poll_id"], :name => "index_poll_participations_on_poll_id"
create_table "polls", :force => true do |t|
t.string "question", :null => false
t.integer "status_message_id", :null => false
t.boolean "status"
t.string "guid"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
add_index "polls", ["status_message_id"], :name => "index_polls_on_status_message_id"
create_table "post_reports", :force => true do |t|
t.integer "post_id", :null => false
t.string "user_id"
@ -502,36 +535,36 @@ ActiveRecord::Schema.define(:version => 20140222162826) do
add_index "users", ["invitation_token"], :name => "index_users_on_invitation_token"
add_index "users", ["username"], :name => "index_users_on_username", :unique => true
add_foreign_key "aspect_memberships", "aspects", :name => "aspect_memberships_aspect_id_fk", :dependent => :delete
add_foreign_key "aspect_memberships", "contacts", :name => "aspect_memberships_contact_id_fk", :dependent => :delete
add_foreign_key "aspect_memberships", "aspects", name: "aspect_memberships_aspect_id_fk", dependent: :delete
add_foreign_key "aspect_memberships", "contacts", name: "aspect_memberships_contact_id_fk", dependent: :delete
add_foreign_key "aspect_visibilities", "aspects", :name => "aspect_visibilities_aspect_id_fk", :dependent => :delete
add_foreign_key "aspect_visibilities", "aspects", name: "aspect_visibilities_aspect_id_fk", dependent: :delete
add_foreign_key "comments", "people", :name => "comments_author_id_fk", :column => "author_id", :dependent => :delete
add_foreign_key "comments", "people", name: "comments_author_id_fk", column: "author_id", dependent: :delete
add_foreign_key "contacts", "people", :name => "contacts_person_id_fk", :dependent => :delete
add_foreign_key "contacts", "people", name: "contacts_person_id_fk", dependent: :delete
add_foreign_key "conversation_visibilities", "conversations", :name => "conversation_visibilities_conversation_id_fk", :dependent => :delete
add_foreign_key "conversation_visibilities", "people", :name => "conversation_visibilities_person_id_fk", :dependent => :delete
add_foreign_key "conversation_visibilities", "conversations", name: "conversation_visibilities_conversation_id_fk", dependent: :delete
add_foreign_key "conversation_visibilities", "people", name: "conversation_visibilities_person_id_fk", dependent: :delete
add_foreign_key "conversations", "people", :name => "conversations_author_id_fk", :column => "author_id", :dependent => :delete
add_foreign_key "conversations", "people", name: "conversations_author_id_fk", column: "author_id", dependent: :delete
add_foreign_key "invitations", "users", :name => "invitations_recipient_id_fk", :column => "recipient_id", :dependent => :delete
add_foreign_key "invitations", "users", :name => "invitations_sender_id_fk", :column => "sender_id", :dependent => :delete
add_foreign_key "invitations", "users", name: "invitations_recipient_id_fk", column: "recipient_id", dependent: :delete
add_foreign_key "invitations", "users", name: "invitations_sender_id_fk", column: "sender_id", dependent: :delete
add_foreign_key "likes", "people", :name => "likes_author_id_fk", :column => "author_id", :dependent => :delete
add_foreign_key "likes", "people", name: "likes_author_id_fk", column: "author_id", dependent: :delete
add_foreign_key "messages", "conversations", :name => "messages_conversation_id_fk", :dependent => :delete
add_foreign_key "messages", "people", :name => "messages_author_id_fk", :column => "author_id", :dependent => :delete
add_foreign_key "messages", "conversations", name: "messages_conversation_id_fk", dependent: :delete
add_foreign_key "messages", "people", name: "messages_author_id_fk", column: "author_id", dependent: :delete
add_foreign_key "notification_actors", "notifications", :name => "notification_actors_notification_id_fk", :dependent => :delete
add_foreign_key "notification_actors", "notifications", name: "notification_actors_notification_id_fk", dependent: :delete
add_foreign_key "posts", "people", :name => "posts_author_id_fk", :column => "author_id", :dependent => :delete
add_foreign_key "posts", "people", name: "posts_author_id_fk", column: "author_id", dependent: :delete
add_foreign_key "profiles", "people", :name => "profiles_person_id_fk", :dependent => :delete
add_foreign_key "profiles", "people", name: "profiles_person_id_fk", dependent: :delete
add_foreign_key "services", "users", :name => "services_user_id_fk", :dependent => :delete
add_foreign_key "services", "users", name: "services_user_id_fk", dependent: :delete
add_foreign_key "share_visibilities", "contacts", :name => "post_visibilities_contact_id_fk", :dependent => :delete
add_foreign_key "share_visibilities", "contacts", name: "post_visibilities_contact_id_fk", dependent: :delete
end

View file

@ -0,0 +1,65 @@
@javascript
Feature: posting with a poll
Background:
Given following users exist:
| username |
| bob |
And I sign in as "bob@bob.bob"
And I am on the home page
Scenario: expanding the publisher
Given "#poll_creator_wrapper" is hidden
When I expand the publisher
Then I should see an element "#poll_creator"
Scenario: expanding the poll creator
Given "#poll_creator_wrapper" is hidden
When I expand the publisher
And I press the element "#poll_creator"
Then I should see an element "#poll_creator_wrapper"
Scenario: adding option to poll
Given "#poll_creator_wrapper" is hidden
When I expand the publisher
And I press the element "#poll_creator"
And I press the element "#add_poll_answer"
Then I should see 3 options
Scenario: delete an option
Given "#poll_creator_wrapper" is hidden
When I expand the publisher
And I press the element "#poll_creator"
And I delete the first option
Then I should see 1 option
And I should not see a remove icon
Scenario: post with an attached poll
Given I expand the publisher
And I press the element "#poll_creator"
When I fill in the following:
| status_message_fake_text | I am eating yogurt |
| poll_question | What kind of yogurt do you like? |
And I fill in the following for the options:
| normal |
| not normal |
And I press "Share"
Then I should see a ".poll_form" within ".stream_element"
And I should see a "form" within ".stream_element"
Scenario: vote for an option
Given I expand the publisher
And I press the element "#poll_creator"
When I fill in the following:
| status_message_fake_text | I am eating yogurt |
| poll_question | What kind of yogurt do you like? |
And I fill in the following for the options:
| normal |
| not normal |
And I press "Share"
And I check the first option
And I press "Vote" within ".stream_element"
Then I should see an element ".poll_progress_bar"
And I should see an element ".percentage"
And I should see "1 vote so far" within ".poll_statistic"

View file

@ -0,0 +1,32 @@
Then /^I should see ([1-9]+) options?$/ do |number|
find("#poll_creator_wrapper").all(".poll_answer").count.should eql(number.to_i)
end
And /^I delete the first option$/ do
find("#poll_creator_wrapper").all(".poll_answer .remove_poll_answer").first.click
end
And /^I should not see a remove icon$/ do
page.should_not have_css(".remove_poll_answer")
end
When /^I fill in the following for the options:$/ do |table|
i = 0
table.raw.flatten.each do |value|
all(".poll_answer_input")[i].set(value)
i+=1
end
end
When /^I check the first option$/ do
sleep 1
first(".poll_form input").click
end
And /^I press the element "([^"]*)"$/ do |selector|
find(selector).click
end
Then /^I should see an element "([^"]*)"$/ do |selector|
page.should have_css(selector)
end

View file

@ -11,8 +11,6 @@ module Federated
FEDERATION_LOGGER.info("user:#{@user.id} dispatching #{relayable.class}:#{relayable.guid}")
Postzord::Dispatcher.defer_build_and_post(@user, relayable)
relayable
else
false
end
end

View file

@ -92,6 +92,12 @@ FactoryGirl.define do
end
end
factory(:status_message_with_poll, :parent => :status_message) do
after(:build) do |sm|
FactoryGirl.create(:poll, :status_message => sm)
end
end
factory(:status_message_with_photo, :parent => :status_message) do
sequence(:text) { |n| "There are #{n} ninjas in this photo." }
after(:build) do |sm|
@ -107,6 +113,18 @@ FactoryGirl.define do
end
end
factory(:poll) do
sequence(:question) { |n| "What do you think about #{n} ninjas?" }
after(:build) do |p|
p.poll_answers << FactoryGirl.build(:poll_answer)
p.poll_answers << FactoryGirl.build(:poll_answer)
end
end
factory(:poll_answer) do
sequence(:answer) { |n| "#{n} questionmarks" }
end
factory(:photo) do
sequence(:random_string) {|n| SecureRandom.hex(10) }
association :author, :factory => :person

View file

@ -0,0 +1,45 @@
describe("app.views.Poll", function(){
beforeEach(function() {
loginAs({name: "alice", avatar : {small : "http://avatar.com/photo.jpg"}});
this.view = new app.views.Poll({ "model" : factory.postWithPoll()});
this.view.render();
});
describe("setProgressBar", function(){
it("sets the progress bar according to the voting result", function(){
var percentage = (this.view.poll.poll_answers[0].vote_count / this.view.poll.participation_count)*100;
expect(this.view.$('.poll_progress_bar:first').css('width')).toBe(this.view.progressBarFactor * percentage+"px");
expect(this.view.$(".percentage:first").text()).toBe(" - " + percentage + "%");
})
});
describe("toggleResult", function(){
it("toggles the progress bar and result", function(){
expect(this.view.$('.poll_progress_bar_wrapper:first').css('display')).toBe("none");
this.view.toggleResult(null);
expect(this.view.$('.poll_progress_bar_wrapper:first').css('display')).toBe("block");
})
});
describe("updateCounter", function(){
it("updates the counter after a vote", function(){
var pc = this.view.poll.participation_count;
var answerCount = this.view.poll.poll_answers[0].vote_count;
this.view.updateCounter(1);
expect(this.view.poll.participation_count).toBe(pc+1);
expect(this.view.poll.poll_answers[0].vote_count).toBe(answerCount+1);
})
});
describe("vote", function(){
it("checks the ajax call for voting", function(){
spyOn($, "ajax");
var radio = this.view.$('input[name="vote"]:first');
radio.attr('checked', true);
this.view.vote({'target' : radio});
var obj = JSON.parse($.ajax.mostRecentCall.args[0].data);
expect(obj.poll_id).toBe(this.view.poll.poll_id);
expect(obj.poll_answer_id).toBe(this.view.poll.poll_answers[0].id);
})
})
});

View file

@ -277,6 +277,40 @@ describe("app.views.Publisher", function() {
});
context("poll", function(){
beforeEach(function() {
loginAs({name: "alice", avatar : {small : "http://avatar.com/photo.jpg"}});
spec.loadFixture("aspects_index");
$("#poll_creator_wrapper").hide(); //css not loaded? :-/
this.view = new app.views.Publisher();
});
describe('#showPollCreator', function(){
it("Shows the poll creator", function(){
expect($("#poll_creator_wrapper").is(":visible")).toBe(false);
this.view.showPollCreator();
expect($("#poll_creator_wrapper").is(":visible")).toBe(true);
})
});
describe("#addPollAnswer", function(){
it("should add a poll answer if clicked", function(){
expect($("#poll_creator_wrapper .poll_answer").length).toBe(2);
this.view.addPollAnswer();
expect($("#poll_creator_wrapper .poll_answer").length).toBe(3);
})
});
describe("#removePollAnswer", function(){
it("should remove a poll answer if clicked", function(){
var answer_count = $('.poll_answer').length;
var evt = {'currentTarget' : $("#poll_creator_wrapper .poll_answer:first .remove_poll_answer")};
this.view.removePollAnswer(evt);
expect($("#poll_creator_wrapper .poll_answer").length).toBe(answer_count-1);
})
});
});
context("locator", function() {
beforeEach(function() {
// should be jasmine helper

View file

@ -124,11 +124,31 @@ factory = {
return new app.models.Post(_.extend(defaultAttrs, overrides))
},
postWithPoll : function(overrides) {
defaultAttrs = _.extend(factory.postAttrs(), {"author" : this.author()});
defaultAttrs = _.extend(defaultAttrs, {"already_participated_in_poll" : false});
defaultAttrs = _.extend(defaultAttrs, {"poll" : factory.poll()});
return new app.models.Post(_.extend(defaultAttrs, overrides));
},
statusMessage : function(overrides){
//intentionally doesn't have an author to mirror creation process, maybe we should change the creation process
return new app.models.StatusMessage(_.extend(factory.postAttrs(), overrides))
},
poll: function(overrides){
return {
"question" : "This is an awesome question",
"created_at" : "2012-01-03T19:53:13Z",
"author" : this.author(),
"post_id" : 1,
"poll_answers" : [{"answer" : "yes", "id" : 1, "vote_count" : 9}, {"answer" : "no", "id" : 2, "vote_count" : 1}],
"guid" : this.guid(),
"poll_id": this.id.next(),
"participation_count" : 10
}
},
comment: function(overrides) {
var defaultAttrs = {
"text" : "This is an awesome comment!",

View file

@ -0,0 +1,21 @@
require 'spec_helper'
describe PollAnswer do
before do
@status = FactoryGirl.create(:status_message_with_poll)
@user = alice
@answer = @status.poll.poll_answers.first
end
describe 'counter cache' do
it 'increments the counter cache on the answer' do
lambda {
alice.participate_in_poll!(@status, @answer)
}.should change{
@answer.reload.vote_count
}.by(1)
end
end
end

View file

@ -0,0 +1,102 @@
require 'spec_helper'
require Rails.root.join("spec", "shared_behaviors", "relayable")
describe PollParticipation do
before do
@alices_aspect = alice.aspects.first
@status = bob.post(:status_message, :text => "hello", :to => bob.aspects.first.id)
@poll = Poll.new(:question => 'Who is in charge?')
@poll.poll_answers.build(:answer => "a")
@poll.poll_answers.build(:answer => "b")
@status.poll = @poll
end
describe 'validation' do
it 'forbids multiple participations in the same poll' do
expect {
2.times do |run|
bob.participate_in_poll!(@status, @poll.poll_answers.first)
end
}.to raise_error
end
it 'allows a one time participation in a poll' do
expect {
bob.participate_in_poll!(@status, @poll.poll_answers.first)
}.to_not raise_error
end
end
describe 'xml' do
before do
@poll_participant = FactoryGirl.create(:user)
@poll_participant_aspect = @poll_participant.aspects.create(:name => "bruisers")
connect_users(alice, @alices_aspect, @poll_participant, @poll_participant_aspect)
@poll = Poll.new(:question => "hi")
@poll.poll_answers.build(:answer => "a")
@poll.poll_answers.build(:answer => "b")
@post = alice.post :status_message, :text => "hello", :to => @alices_aspect.id
@post.poll = @poll
@poll_participation = @poll_participant.participate_in_poll!(@post, @poll.poll_answers.first)
@xml = @poll_participation.to_xml.to_s
end
it 'serializes the class name' do
@xml.include?(PollParticipation.name.underscore.to_s).should be_true
end
it 'serializes the sender handle' do
@xml.include?(@poll_participation.diaspora_handle).should be_true
end
it 'serializes the poll_guid' do
@xml.should include(@poll.guid)
end
it 'serializes the poll_answer_guid' do
@xml.should include(@poll_participation.poll_answer.guid)
end
describe 'marshalling' do
before do
@marshalled_poll_participation = PollParticipation.from_xml(@xml)
end
it 'marshals the author' do
@marshalled_poll_participation.author.should == @poll_participant.person
end
it 'marshals the answer' do
@marshalled_poll_participation.poll_answer.should == @poll_participation.poll_answer
end
it 'marshals the poll' do
@marshalled_poll_participation.poll.should == @poll
end
end
end
describe 'it is relayable' do
before do
@local_luke, @local_leia, @remote_raphael = set_up_friends
@remote_parent = FactoryGirl.build(:status_message_with_poll, :author => @remote_raphael)
@local_parent = @local_luke.post :status_message, :text => "hi", :to => @local_luke.aspects.first
@poll2 = Poll.new(:question => 'Who is now in charge?')
@poll2.poll_answers.build(:answer => "a")
@poll2.poll_answers.build(:answer => "b")
@local_parent.poll = @poll2
@object_by_parent_author = @local_luke.participate_in_poll!(@local_parent, @poll2.poll_answers.first)
@object_by_recipient = @local_leia.participate_in_poll!(@local_parent, @poll2.poll_answers.first)
@dup_object_by_parent_author = @object_by_parent_author.dup
@object_on_remote_parent = @local_luke.participate_in_poll!(@remote_parent, @remote_parent.poll.poll_answers.first)
end
let(:build_object) { PollParticipation::Generator.new(alice, @status, @poll.poll_answers.first).build }
it_should_behave_like 'it is relayable'
end
end

20
spec/models/poll_spec.rb Normal file
View file

@ -0,0 +1,20 @@
require 'spec_helper'
describe Poll do
before do
@poll = Poll.new(:question => "What do you think about apples?")
end
describe 'validation' do
it 'should not create a poll when it has less than two answers' do
@poll.poll_answers.build(:answer => '1')
@poll.should_not be_valid
end
it 'should create a poll when it has more than two answers' do
@poll.poll_answers.build(:answer => '1')
@poll.poll_answers.build(:answer => '2')
@poll.should be_valid
end
end
end

View file

@ -350,6 +350,35 @@ STR
end
end
end
context 'with a poll' do
before do
@message.poll = FactoryGirl.create(:poll, :status_message => @message)
@xml = @message.to_xml.to_s
end
it 'serializes the poll' do
@xml.should include "poll"
@xml.should include "question"
@xml.should include "poll_answer"
end
describe ".from_xml" do
before do
@marshalled = StatusMessage.from_xml(@xml)
end
it 'marshals the poll' do
@marshalled.poll.should be_present
end
it 'marshals the poll answers' do
@marshalled.poll.poll_answers.size.should == 2
end
end
end
end
describe '#after_dispatch' do

View file

@ -16,7 +16,7 @@ describe User::SocialActions do
alice.participations.last.target.should == @status
end
it "creates the like" do
it "creates the comment" do
lambda{ alice.comment!(@status, "bro") }.should change(Comment, :count).by(1)
end
@ -83,4 +83,31 @@ describe User::SocialActions do
@status.reload.likes.should == likes
end
end
describe 'User#participate_in_poll!' do
before do
@bobs_aspect = bob.aspects.where(:name => "generic").first
@status = bob.post(:status_message, :text => "hello", :to => @bobs_aspect.id)
@poll = FactoryGirl.create(:poll, :status_message => @status)
@answer = @poll.poll_answers.first
end
it "federates" do
Participation::Generator.any_instance.stub(:create!)
Postzord::Dispatcher.should_receive(:defer_build_and_post)
alice.participate_in_poll!(@status, @answer)
end
it "creates a partcipation" do
lambda{ alice.participate_in_poll!(@status, @answer) }.should change(Participation, :count).by(1)
end
it "creates the poll participation" do
lambda{ alice.participate_in_poll!(@status, @answer) }.should change(PollParticipation, :count).by(1)
end
it "sets the poll answer id" do
alice.participate_in_poll!(@status, @answer).poll_answer.should == @answer
end
end
end