Use typeahead on conversations

This commit is contained in:
Augier 2016-09-30 12:04:04 +02:00 committed by Benjamin Neff
parent 5269a0d3c0
commit f2fdaf1daf
27 changed files with 749 additions and 262 deletions

View file

@ -79,6 +79,9 @@ app.pages.Contacts = Backbone.View.extend({
}, },
showMessageModal: function(){ showMessageModal: function(){
$("#conversationModal").on("modal:loaded", function() {
new app.views.ConversationsForm({prefill: gon.conversationPrefill});
});
app.helpers.showModal("#conversationModal"); app.helpers.showModal("#conversationModal");
}, },

View file

@ -5,40 +5,83 @@ app.views.ConversationsForm = Backbone.View.extend({
events: { events: {
"keydown .conversation-message-text": "keyDown", "keydown .conversation-message-text": "keyDown",
"click .conversation-recipient-tag .remove": "removeRecipient"
}, },
initialize: function(opts) { initialize: function(opts) {
this.contacts = _.has(opts, "contacts") ? opts.contacts : null; opts = opts || {};
this.prefill = []; this.conversationRecipients = [];
if (_.has(opts, "prefillName") && _.has(opts, "prefillValue")) {
this.prefill = [{name: opts.prefillName, value: opts.prefillValue}]; this.typeaheadElement = this.$el.find("#contacts-search-input");
this.contactsIdsListInput = this.$el.find("#contact-ids");
this.tagListElement = this.$("#recipients-tag-list");
this.search = new app.views.SearchBase({
el: this.$el.find("#new-conversation"),
typeaheadInput: this.typeaheadElement,
customSearch: true,
autoselect: true,
remoteRoute: {url: "/contacts", extraParameters: "mutual=true"}
});
this.bindTypeaheadEvents();
this.tagListElement.empty();
if (opts.prefill) {
this.prefill(opts.prefill);
} }
this.prepareAutocomplete(this.contacts);
this.$("form#new-conversation").on("ajax:success", this.conversationCreateSuccess); this.$("form#new-conversation").on("ajax:success", this.conversationCreateSuccess);
this.$("form#new-conversation").on("ajax:error", this.conversationCreateError); this.$("form#new-conversation").on("ajax:error", this.conversationCreateError);
}, },
prepareAutocomplete: function(data){ addRecipient: function(person) {
this.$("#contact-autocomplete").autoSuggest(data, { this.conversationRecipients.push(person);
selectedItemProp: "name", this.updateContactIdsListInput();
searchObjProps: "name", /* eslint-disable camelcase */
asHtmlID: "contact_ids", this.tagListElement.append(HandlebarsTemplates.conversation_recipient_tag_tpl(person));
retrieveLimit: 10, /* eslint-enable camelcase */
minChars: 1,
keyDelay: 0,
startText: '',
emptyText: Diaspora.I18n.t("no_results"),
preFill: this.prefill
});
$("#contact_ids").attr("aria-labelledby", "toLabel").focus();
}, },
keyDown : function(evt) { prefill: function(handles) {
if(evt.which === Keycodes.ENTER && evt.ctrlKey) { handles.forEach(this.addRecipient.bind(this));
},
updateContactIdsListInput: function() {
this.contactsIdsListInput.val(_(this.conversationRecipients).pluck("id").join(","));
this.search.ignoreDiasporaIds.length = 0;
this.conversationRecipients.forEach(this.search.ignorePersonForSuggestions.bind(this.search));
},
bindTypeaheadEvents: function() {
this.typeaheadElement.on("typeahead:select", function(evt, person) {
this.onSuggestionSelection(person);
}.bind(this));
},
onSuggestionSelection: function(person) {
this.addRecipient(person);
this.typeaheadElement.typeahead("val", "");
},
keyDown: function(evt) {
if (evt.which === Keycodes.ENTER && evt.ctrlKey) {
$(evt.target).parents("form").submit(); $(evt.target).parents("form").submit();
} }
}, },
removeRecipient: function(evt) {
var $recipientTagEl = $(evt.target).parents(".conversation-recipient-tag");
var diasporaHandle = $recipientTagEl.data("diaspora-handle");
this.conversationRecipients = this.conversationRecipients.filter(function(person) {
return diasporaHandle.localeCompare(person.handle) !== 0;
});
this.updateContactIdsListInput();
$recipientTagEl.remove();
},
conversationCreateSuccess: function(evt, data) { conversationCreateSuccess: function(evt, data) {
app._changeLocation(Routes.conversation(data.id)); app._changeLocation(Routes.conversation(data.id));
}, },

View file

@ -9,7 +9,7 @@ app.views.ConversationsInbox = Backbone.View.extend({
}, },
initialize: function() { initialize: function() {
new app.views.ConversationsForm({contacts: gon.contacts}); new app.views.ConversationsForm();
this.setupConversation(); this.setupConversation();
}, },

View file

@ -79,8 +79,11 @@ app.views.ProfileHeader = app.views.Base.extend({
}, },
showMessageModal: function(){ showMessageModal: function(){
$("#conversationModal").on("modal:loaded", function() {
new app.views.ConversationsForm({prefill: gon.conversationPrefill});
});
app.helpers.showModal("#conversationModal"); app.helpers.showModal("#conversationModal");
}, }
}); });
// @license-end // @license-end

View file

@ -32,7 +32,7 @@ app.views.PublisherMention = app.views.SearchBase.extend({
typeaheadInput: this.typeaheadInput, typeaheadInput: this.typeaheadInput,
customSearch: true, customSearch: true,
autoselect: true, autoselect: true,
remoteRoute: "/contacts" remoteRoute: {url: "/contacts"}
}); });
}, },

View file

@ -28,9 +28,13 @@ app.views.SearchBase = app.views.Base.extend({
}; };
// Allow bloodhound to look for remote results if there is a route given in the options // Allow bloodhound to look for remote results if there is a route given in the options
if(options.remoteRoute) { if (options.remoteRoute && options.remoteRoute.url) {
var extraParameters = "";
if (options.remoteRoute.extraParameters) {
extraParameters += "&" + options.remoteRoute.extraParameters;
}
bloodhoundOptions.remote = { bloodhoundOptions.remote = {
url: options.remoteRoute + ".json?q=%QUERY", url: options.remoteRoute.url + ".json?q=%QUERY" + extraParameters,
wildcard: "%QUERY", wildcard: "%QUERY",
transform: this.transformBloodhoundResponse.bind(this) transform: this.transformBloodhoundResponse.bind(this)
}; };

View file

@ -10,7 +10,7 @@ app.views.Search = app.views.SearchBase.extend({
this.searchInput = this.$("#q"); this.searchInput = this.$("#q");
app.views.SearchBase.prototype.initialize.call(this, { app.views.SearchBase.prototype.initialize.call(this, {
typeaheadInput: this.searchInput, typeaheadInput: this.searchInput,
remoteRoute: this.$el.attr("action"), remoteRoute: {url: this.$el.attr("action")},
suggestionLink: true suggestionLink: true
}); });
this.searchInput.on("typeahead:select", this.suggestionSelected); this.searchInput.on("typeahead:select", this.suggestionSelected);

View file

@ -183,10 +183,60 @@
} }
// scss-lint:enable SelectorDepth // scss-lint:enable SelectorDepth
#new_conversation_pane { .new-conversation {
ul.as-selections { width: 100% !important; } ul.as-selections { width: 100% !important; }
input#contact_ids { box-shadow: none; } input#contact_ids { box-shadow: none; }
label { font-weight: bold; } label { font-weight: bold; }
.twitter-typeahead,
.tt-menu {
width: 100%;
}
}
.recipients-tag-list {
.conversation-recipient-tag {
background-color: $brand-primary;
border-radius: $btn-border-radius-base;
display: inline-flex;
margin: 0 2px $form-group-margin-bottom;
padding: 8px;
&:first-child { margin-left: 0; }
&:last-child { margin-right: 0; }
div {
align-self: center;
justify-content: flex-start;
}
}
.avatar {
height: 40px;
margin-right: 8px;
width: 40px;
}
.name-and-handle {
color: $white;
margin-right: 8px;
text-align: left;
.diaspora-id { font-size: $font-size-small; }
}
.entypo-circled-cross {
color: $white;
cursor: pointer;
font-size: 20px;
height: 22px;
line-height: 22px;
&:hover { color: $light-grey; }
}
} }
.new-conversation.form-horizontal .form-group:last-of-type { margin-bottom: 0; } .new-conversation.form-horizontal .form-group:last-of-type { margin-bottom: 0; }

View file

@ -61,3 +61,5 @@
.subject { padding: 0 10px; } .subject { padding: 0 10px; }
.message-count, .unread-message-count { margin: 10px 2px; } .message-count, .unread-message-count { margin: 10px 2px; }
.new-conversation .as-selections { background-color: transparent; }

View file

@ -0,0 +1,12 @@
<div class="conversation-recipient-tag clearfix" data-diaspora-handle="{{ handle }}">
<div href="{{ url }}">
<img src="{{ avatar }}" class="avatar img-responsive center-block">
</div>
<div class="pull-left clearfix name-and-handle" href="{{ url }}">
<div class="name">{{ name }}</div>
<div class="diaspora-id">{{ handle }}</div>
</div>
<div class="remove pull-right clearfix">
<i class="entypo-circled-cross"></i>
</div>
</div>

View file

@ -17,7 +17,8 @@ class ContactsController < ApplicationController
# Used for mentions in the publisher and pagination on the contacts page # Used for mentions in the publisher and pagination on the contacts page
format.json { format.json {
@people = if params[:q].present? @people = if params[:q].present?
Person.search(params[:q], current_user, only_contacts: true).limit(15) mutual = params[:mutual].present? && params[:mutual]
Person.search(params[:q], current_user, only_contacts: true, mutual: mutual).limit(15)
else else
set_up_contacts_json set_up_contacts_json
end end

View file

@ -30,12 +30,12 @@ class ConversationsController < ApplicationController
end end
def create def create
contact_ids = params[:contact_ids] # Contacts autocomplete does not work the same way on mobile and desktop
# Mobile returns contact ids array while desktop returns person id
# Can't split nil # This will have to be removed when mobile autocomplete is ported to Typeahead
if contact_ids recipients_param, column = [%i(contact_ids id), %i(person_ids person_id)].find {|param, _| params[param].present? }
contact_ids = contact_ids.split(',') if contact_ids.is_a? String if recipients_param
person_ids = current_user.contacts.where(id: contact_ids).pluck(:person_id) person_ids = current_user.contacts.where(column => params[recipients_param].split(",")).pluck(:person_id)
end end
opts = params.require(:conversation).permit(:subject) opts = params.require(:conversation).permit(:subject)
@ -91,17 +91,23 @@ class ConversationsController < ApplicationController
return return
end end
@contacts_json = contacts_data.to_json
@contact_ids = ""
if params[:contact_id]
@contact_ids = current_user.contacts.find(params[:contact_id]).id
elsif params[:aspect_id]
@contact_ids = current_user.aspects.find(params[:aspect_id]).contacts.map{|c| c.id}.join(',')
end
if session[:mobile_view] == true && request.format.html? if session[:mobile_view] == true && request.format.html?
@contacts_json = contacts_data.to_json
@contact_ids = if params[:contact_id]
current_user.contacts.find(params[:contact_id]).id
elsif params[:aspect_id]
current_user.aspects.find(params[:aspect_id]).contacts.pluck(:id).join(",")
end
render :layout => true render :layout => true
else else
if params[:contact_id]
gon.push conversation_prefill: [current_user.contacts.find(params[:contact_id]).person.as_json]
elsif params[:aspect_id]
gon.push conversation_prefill: current_user.aspects
.find(params[:aspect_id]).contacts.map {|c| c.person.as_json }
end
render :layout => false render :layout => false
end end
end end

View file

@ -145,7 +145,7 @@ class Person < ActiveRecord::Base
[where_clause, q_tokens] [where_clause, q_tokens]
end end
def self.search(search_str, user, only_contacts: false) def self.search(search_str, user, only_contacts: false, mutual: false)
search_str.strip! search_str.strip!
return none if search_str.blank? || search_str.size < 2 return none if search_str.blank? || search_str.size < 2
@ -159,6 +159,8 @@ class Person < ActiveRecord::Base
).searchable(user) ).searchable(user)
end end
query = query.where(contacts: {sharing: true, receiving: true}) if mutual
query.where(closed_account: false) query.where(closed_account: false)
.where(sql, *tokens) .where(sql, *tokens)
.includes(:profile) .includes(:profile)

View file

@ -34,7 +34,7 @@
.spinner .spinner
-if @aspect -if @aspect
#new_conversation_pane .conversations-form-container#new_conversation_pane
= render 'shared/modal', = render 'shared/modal',
:path => new_conversation_path(:aspect_id => @aspect.id, :name => @aspect.name, :modal => true), :path => new_conversation_path(:aspect_id => @aspect.id, :name => @aspect.name, :modal => true),
:title => t('conversations.index.new_conversation'), :title => t('conversations.index.new_conversation'),

View file

@ -2,9 +2,13 @@
= form_for Conversation.new, html: {id: "new-conversation", = form_for Conversation.new, html: {id: "new-conversation",
class: "new-conversation form-horizontal"}, remote: true do |conversation| class: "new-conversation form-horizontal"}, remote: true do |conversation|
.form-group .form-group
%label#toLabel{for: "contact_ids"} %label#to-label{for: "contacts-search-input"}= t(".to")
= t(".to") .recipients-tag-list.clearfix#recipients-tag-list
= text_field_tag "contact_autocomplete", nil, id: "contact-autocomplete", class: "form-control" = text_field_tag "contact_autocomplete", nil, id: "contacts-search-input", class: "form-control"
- unless defined?(mobile) && mobile
= text_field_tag "person_ids", nil, id: "contact-ids", type: "hidden",
aria: {labelledby: "to-label"}
.form-group .form-group
%label#subject-label{for: "conversation-subject"} %label#subject-label{for: "conversation-subject"}
= t(".subject") = t(".subject")
@ -14,12 +18,14 @@
aria: {labelledby: "subject-label"}, aria: {labelledby: "subject-label"},
value: "", value: "",
placeholder: t("conversations.new.subject_default") placeholder: t("conversations.new.subject_default")
.form-group .form-group
%label.sr-only#message-label{for: "new-message-text"} = t(".message") %label.sr-only#message-label{for: "new-message-text"}= t(".message")
= text_area_tag "conversation[text]", "", = text_area_tag "conversation[text]", "",
rows: 5, rows: 5,
id: "new-message-text", id: "new-message-text",
class: "conversation-message-text input-block-level form-control", class: "conversation-message-text input-block-level form-control",
aria: {labelledby: "message-label"} aria: {labelledby: "message-label"}
.form-group .form-group
= conversation.submit t(".send"), "data-disable-with" => t(".sending"), :class => "btn btn-primary pull-right" = conversation.submit t(".send"), "data-disable-with" => t(".sending"), :class => "btn btn-primary pull-right"

View file

@ -1,12 +1,2 @@
:javascript = include_gon camel_case: true
$(document).ready(function () {
var data = $.parseJSON( "#{escape_javascript(@contacts_json)}" );
new app.views.ConversationsForm({
el: $("form#new-conversation").parent(),
contacts: data,
prefillName: "#{h params[:name]}",
prefillValue: "#{@contact_ids}"
});
});
= render 'conversations/new' = render 'conversations/new'

View file

@ -6,7 +6,7 @@
:plain :plain
$(document).ready(function () { $(document).ready(function () {
var data = $.parseJSON( "#{escape_javascript(@contacts_json).html_safe}" ), var data = $.parseJSON( "#{escape_javascript(@contacts_json).html_safe}" ),
autocompleteInput = $("#contact-autocomplete"); autocompleteInput = $("#contacts-search-input");
autocompleteInput.autoSuggest(data, { autocompleteInput.autoSuggest(data, {
selectedItemProp: "name", selectedItemProp: "name",
@ -15,7 +15,7 @@
retrieveLimit: 10, retrieveLimit: 10,
minChars: 1, minChars: 1,
keyDelay: 0, keyDelay: 0,
startText: '', startText: "",
emptyText: "#{t("no_results")}", emptyText: "#{t("no_results")}",
preFill: [{name : "#{h params[:name]}", preFill: [{name : "#{h params[:name]}",
value : "#{@contact_ids}"}] value : "#{@contact_ids}"}]
@ -27,6 +27,6 @@
#flash-messages #flash-messages
.container-fluid.row .container-fluid.row
%h3 %h3
= t('conversations.index.new_conversation') = t("conversations.index.new_conversation")
= render 'conversations/new' = render "conversations/new", mobile: true

View file

@ -40,7 +40,7 @@
id: 'mentionModal' id: 'mentionModal'
-if @contact -if @contact
#new_conversation_pane .conversations-form-container#new_conversation_pane
= render 'shared/modal', = render 'shared/modal',
path: new_conversation_path(:contact_id => @contact.id, name: @contact.person.name, modal: true), path: new_conversation_path(:contact_id => @contact.id, name: @contact.person.name, modal: true),
title: t('conversations.index.new_conversation'), title: t('conversations.index.new_conversation'),

View file

@ -15,8 +15,8 @@ end
Then /^I send a message with subject "([^"]*)" and text "([^"]*)" to "([^"]*)"$/ do |subject, text, person| Then /^I send a message with subject "([^"]*)" and text "([^"]*)" to "([^"]*)"$/ do |subject, text, person|
step %(I am on the conversations page) step %(I am on the conversations page)
within("#new-conversation", match: :first) do within("#new-conversation", match: :first) do
step %(I fill in "contact_autocomplete" with "#{person}") find("#contacts-search-input").native.send_key(person.to_s)
step %(I press the first ".as-result-item" within ".as-results") step %(I press the first ".tt-suggestion" within ".twitter-typeahead")
step %(I fill in "conversation-subject" with "#{subject}") step %(I fill in "conversation-subject" with "#{subject}")
step %(I fill in "new-message-text" with "#{text}") step %(I fill in "new-message-text" with "#{text}")
step %(I press "Send") step %(I press "Send")
@ -26,8 +26,8 @@ end
Then /^I send a message with subject "([^"]*)" and text "([^"]*)" to "([^"]*)" using keyboard shortcuts$/ do |subject, text, person| Then /^I send a message with subject "([^"]*)" and text "([^"]*)" to "([^"]*)" using keyboard shortcuts$/ do |subject, text, person|
step %(I am on the conversations page) step %(I am on the conversations page)
within("#new-conversation", match: :first) do within("#new-conversation", match: :first) do
step %(I fill in "contact_autocomplete" with "#{person}") find("#contacts-search-input").native.send_key(person.to_s)
step %(I press the first ".as-result-item" within ".as-results") step %(I press the first ".tt-suggestion" within ".twitter-typeahead")
step %(I fill in "conversation-subject" with "#{subject}") step %(I fill in "conversation-subject" with "#{subject}")
step %(I fill in "new-message-text" with "#{text}") step %(I fill in "new-message-text" with "#{text}")
find("#new-message-text").native.send_key %i(Ctrl Return) find("#new-message-text").native.send_key %i(Ctrl Return)

View file

@ -37,6 +37,8 @@ describe ContactsController, :type => :controller do
@person1 = FactoryGirl.create(:person) @person1 = FactoryGirl.create(:person)
bob.share_with(@person1, bob.aspects.first) bob.share_with(@person1, bob.aspects.first)
@person2 = FactoryGirl.create(:person) @person2 = FactoryGirl.create(:person)
@person3 = FactoryGirl.create(:person)
bob.contacts.create(person: @person3, aspects: [bob.aspects.first], receiving: true, sharing: true)
end end
it "succeeds" do it "succeeds" do
@ -53,6 +55,15 @@ describe ContactsController, :type => :controller do
get :index, q: @person2.first_name, format: "json" get :index, q: @person2.first_name, format: "json"
expect(response.body).to eq([].to_json) expect(response.body).to eq([].to_json)
end end
it "only returns mutual contacts when mutual parameter is true" do
get :index, q: @person1.first_name, mutual: true, format: "json"
expect(response.body).to eq([].to_json)
get :index, q: @person2.first_name, mutual: true, format: "json"
expect(response.body).to eq([].to_json)
get :index, q: @person3.first_name, mutual: true, format: "json"
expect(response.body).to eq([@person3].to_json)
end
end end
context "for pagination on the contacts page" do context "for pagination on the contacts page" do

View file

@ -16,48 +16,57 @@ describe ConversationsController, :type => :controller do
end end
end end
describe '#new modal' do describe "#new modal" do
it 'succeeds' do context "desktop and mobile" do
get :new, :modal => true it "succeeds" do
expect(response).to be_success get :new, modal: true
end expect(response).to be_success
end
it "assigns a json list of contacts that are sharing with the person" do it "assigns a contact if passed a contact id" do
sharing_user = FactoryGirl.create(:user_with_aspect) get :new, contact_id: alice.contacts.first.id, modal: true
sharing_user.share_with(alice.person, sharing_user.aspects.first) expect(controller.gon.conversation_prefill).to eq([alice.contacts.first.person.as_json])
get :new, :modal => true end
expect(assigns(:contacts_json)).to include(alice.contacts.where(sharing: true, receiving: true).first.person.name)
alice.contacts << Contact.new(:person_id => eve.person.id, :user_id => alice.id, :sharing => false, :receiving => true)
expect(assigns(:contacts_json)).not_to include(alice.contacts.where(sharing: false).first.person.name)
expect(assigns(:contacts_json)).not_to include(alice.contacts.where(receiving: false).first.person.name)
end
it "assigns a contact if passed a contact id" do it "assigns a set of contacts if passed an aspect id" do
get :new, :contact_id => alice.contacts.first.id, :modal => true get :new, aspect_id: alice.aspects.first.id, modal: true
expect(assigns(:contact_ids)).to eq(alice.contacts.first.id) expect(controller.gon.conversation_prefill).to eq(alice.aspects.first.contacts.map {|c| c.person.as_json })
end end
it "assigns a set of contacts if passed an aspect id" do it "does not allow XSS via the name parameter" do
get :new, :aspect_id => alice.aspects.first.id, :modal => true ["</script><script>alert(1);</script>",
expect(assigns(:contact_ids)).to eq(alice.aspects.first.contacts.map(&:id).join(',')) '"}]});alert(1);(function f() {var foo = [{b:"'].each do |xss|
end get :new, modal: true, name: xss
expect(response.body).not_to include xss
it "does not allow XSS via the name parameter" do end
["</script><script>alert(1);</script>",
'"}]});alert(1);(function f() {var foo = [{b:"'].each do |xss|
get :new, :modal => true, name: xss
expect(response.body).not_to include xss
end end
end end
it "does not allow XSS via the profile name" do context "mobile" do
xss = "<script>alert(0);</script>" before do
contact = alice.contacts.first controller.session[:mobile_view] = true
contact.person.profile.update_attribute(:first_name, xss) end
get :new, :modal => true
json = JSON.parse(assigns(:contacts_json)).first it "assigns a json list of contacts that are sharing with the person" do
expect(json['value'].to_s).to eq(contact.id.to_s) sharing_user = FactoryGirl.create(:user_with_aspect)
expect(json['name']).to_not include(xss) sharing_user.share_with(alice.person, sharing_user.aspects.first)
get :new, modal: true
expect(assigns(:contacts_json))
.to include(alice.contacts.where(sharing: true, receiving: true).first.person.name)
alice.contacts << Contact.new(person_id: eve.person.id, user_id: alice.id, sharing: false, receiving: true)
expect(assigns(:contacts_json)).not_to include(alice.contacts.where(sharing: false).first.person.name)
expect(assigns(:contacts_json)).not_to include(alice.contacts.where(receiving: false).first.person.name)
end
it "does not allow XSS via the profile name" do
xss = "<script>alert(0);</script>"
contact = alice.contacts.first
contact.person.profile.update_attribute(:first_name, xss)
get :new, modal: true
json = JSON.parse(assigns(:contacts_json)).first
expect(json["value"].to_s).to eq(contact.id.to_s)
expect(json["name"]).to_not include(xss)
end
end end
end end
@ -115,203 +124,323 @@ describe ConversationsController, :type => :controller do
end end
end end
describe '#create' do describe "#create" do
context 'with a valid conversation' do context "desktop" do
before do context "with a valid conversation" do
@hash = { before do
:format => :js, @hash = {
:conversation => { format: :js,
:subject => "secret stuff", conversation: {subject: "secret stuff", text: "text debug"},
:text => 'text debug' person_ids: [alice.contacts.first.person.id]
},
:contact_ids => [alice.contacts.first.id]
}
end
it 'creates a conversation' do
expect {
post :create, @hash
}.to change(Conversation, :count).by(1)
end
it 'creates a message' do
expect {
post :create, @hash
}.to change(Message, :count).by(1)
end
it "responds with the conversation id as JSON" do
post :create, @hash
expect(response).to be_success
expect(JSON.parse(response.body)["id"]).to eq(Conversation.first.id)
end
it 'sets the author to the current_user' do
@hash[:author] = FactoryGirl.create(:user)
post :create, @hash
expect(Message.first.author).to eq(alice.person)
expect(Conversation.first.author).to eq(alice.person)
end
it 'dispatches the conversation' do
cnv = Conversation.create(
{
:author => alice.person,
:participant_ids => [alice.contacts.first.person.id, alice.person.id],
:subject => 'not spam',
:messages_attributes => [ {:author => alice.person, :text => 'cool stuff'} ]
} }
) end
expect(Diaspora::Federation::Dispatcher).to receive(:defer_dispatch) it "creates a conversation" do
post :create, @hash expect { post :create, @hash }.to change(Conversation, :count).by(1)
end end
end
context 'with empty subject' do it "creates a message" do
before do expect { post :create, @hash }.to change(Message, :count).by(1)
@hash = { end
:format => :js,
:conversation => {
:subject => ' ',
:text => 'text debug'
},
:contact_ids => [alice.contacts.first.id]
}
end
it 'creates a conversation' do it "responds with the conversation id as JSON" do
expect {
post :create, @hash post :create, @hash
}.to change(Conversation, :count).by(1) expect(response).to be_success
end expect(JSON.parse(response.body)["id"]).to eq(Conversation.first.id)
end
it 'creates a message' do it "sets the author to the current_user" do
expect { @hash[:author] = FactoryGirl.create(:user)
post :create, @hash post :create, @hash
}.to change(Message, :count).by(1) expect(Message.first.author).to eq(alice.person)
expect(Conversation.first.author).to eq(alice.person)
end
it "dispatches the conversation" do
Conversation.create(author: alice.person, participant_ids: [alice.contacts.first.person.id, alice.person.id],
subject: "not spam", messages_attributes: [{author: alice.person, text: "cool stuff"}])
expect(Diaspora::Federation::Dispatcher).to receive(:defer_dispatch)
post :create, @hash
end
end end
it "responds with the conversation id as JSON" do context "with empty subject" do
post :create, @hash before do
expect(response).to be_success @hash = {
expect(JSON.parse(response.body)["id"]).to eq(Conversation.first.id) format: :js,
conversation: {subject: " ", text: "text debug"},
person_ids: [alice.contacts.first.person.id]
}
end
it "creates a conversation" do
expect { post :create, @hash }.to change(Conversation, :count).by(1)
end
it "creates a message" do
expect { post :create, @hash }.to change(Message, :count).by(1)
end
it "responds with the conversation id as JSON" do
post :create, @hash
expect(response).to be_success
expect(JSON.parse(response.body)["id"]).to eq(Conversation.first.id)
end
end
context "with empty text" do
before do
@hash = {
format: :js,
conversation: {subject: "secret stuff", text: " "},
person_ids: [alice.contacts.first.person.id]
}
end
it "does not create a conversation" do
count = Conversation.count
post :create, @hash
expect(Conversation.count).to eq(count)
end
it "does not create a message" do
count = Message.count
post :create, @hash
expect(Message.count).to eq(count)
end
it "responds with an error message" do
post :create, @hash
expect(response).not_to be_success
expect(response.body).to eq(I18n.t("conversations.create.fail"))
end
end
context "with empty contact" do
before do
@hash = {
format: :js,
conversation: {subject: "secret stuff", text: "text debug"},
person_ids: " "
}
end
it "does not create a conversation" do
count = Conversation.count
post :create, @hash
expect(Conversation.count).to eq(count)
end
it "does not create a message" do
count = Message.count
post :create, @hash
expect(Message.count).to eq(count)
end
it "responds with an error message" do
post :create, @hash
expect(response).not_to be_success
expect(response.body).to eq(I18n.t("javascripts.conversation.create.no_recipient"))
end
end
context "with nil contact" do
before do
@hash = {
format: :js,
conversation: {subject: "secret stuff", text: "text debug"},
person_ids: nil
}
end
it "does not create a conversation" do
count = Conversation.count
post :create, @hash
expect(Conversation.count).to eq(count)
end
it "does not create a message" do
count = Message.count
post :create, @hash
expect(Message.count).to eq(count)
end
it "responds with an error message" do
post :create, @hash
expect(response).not_to be_success
expect(response.body).to eq(I18n.t("javascripts.conversation.create.no_recipient"))
end
end end
end end
context 'with empty text' do context "mobile" do
before do before do
@hash = { controller.session[:mobile_view] = true
:format => :js,
:conversation => {
:subject => 'secret stuff',
:text => ' '
},
:contact_ids => [alice.contacts.first.id]
}
end end
it 'does not create a conversation' do context "with a valid conversation" do
count = Conversation.count before do
post :create, @hash @hash = {
expect(Conversation.count).to eq(count) format: :js,
conversation: {subject: "secret stuff", text: "text debug"},
contact_ids: [alice.contacts.first.id]
}
end
it "creates a conversation" do
expect { post :create, @hash }.to change(Conversation, :count).by(1)
end
it "creates a message" do
expect { post :create, @hash }.to change(Message, :count).by(1)
end
it "responds with the conversation id as JSON" do
post :create, @hash
expect(response).to be_success
expect(JSON.parse(response.body)["id"]).to eq(Conversation.first.id)
end
it "sets the author to the current_user" do
@hash[:author] = FactoryGirl.create(:user)
post :create, @hash
expect(Message.first.author).to eq(alice.person)
expect(Conversation.first.author).to eq(alice.person)
end
it "dispatches the conversation" do
Conversation.create(author: alice.person, participant_ids: [alice.contacts.first.person.id, alice.person.id],
subject: "not spam", messages_attributes: [{author: alice.person, text: "cool stuff"}])
expect(Diaspora::Federation::Dispatcher).to receive(:defer_dispatch)
post :create, @hash
end
end end
it 'does not create a message' do context "with empty subject" do
count = Message.count before do
post :create, @hash @hash = {
expect(Message.count).to eq(count) format: :js,
conversation: {subject: " ", text: "text debug"},
contact_ids: [alice.contacts.first.id]
}
end
it "creates a conversation" do
expect { post :create, @hash }.to change(Conversation, :count).by(1)
end
it "creates a message" do
expect { post :create, @hash }.to change(Message, :count).by(1)
end
it "responds with the conversation id as JSON" do
post :create, @hash
expect(response).to be_success
expect(JSON.parse(response.body)["id"]).to eq(Conversation.first.id)
end
end end
it "responds with an error message" do context "with empty text" do
post :create, @hash before do
expect(response).not_to be_success @hash = {
expect(response.body).to eq(I18n.t("conversations.create.fail")) format: :js,
end conversation: {subject: "secret stuff", text: " "},
end contact_ids: [alice.contacts.first.id]
}
end
context 'with empty contact' do it "does not create a conversation" do
before do count = Conversation.count
@hash = { post :create, @hash
:format => :js, expect(Conversation.count).to eq(count)
:conversation => { end
:subject => 'secret stuff',
:text => 'text debug' it "does not create a message" do
}, count = Message.count
:contact_ids => ' ' post :create, @hash
} expect(Message.count).to eq(count)
end
it "responds with an error message" do
post :create, @hash
expect(response).not_to be_success
expect(response.body).to eq(I18n.t("conversations.create.fail"))
end
end end
it 'does not create a conversation' do context "with empty contact" do
count = Conversation.count before do
post :create, @hash @hash = {
expect(Conversation.count).to eq(count) format: :js,
conversation: {subject: "secret stuff", text: "text debug"},
contact_ids: " "
}
end
it "does not create a conversation" do
count = Conversation.count
post :create, @hash
expect(Conversation.count).to eq(count)
end
it "does not create a message" do
count = Message.count
post :create, @hash
expect(Message.count).to eq(count)
end
it "responds with an error message" do
post :create, @hash
expect(response).not_to be_success
expect(response.body).to eq(I18n.t("javascripts.conversation.create.no_recipient"))
end
end end
it 'does not create a message' do context "with nil contact" do
count = Message.count before do
post :create, @hash @hash = {
expect(Message.count).to eq(count) format: :js,
end conversation: {subject: "secret stuff", text: "text debug"},
contact_ids: nil
}
end
it "responds with an error message" do it "does not create a conversation" do
post :create, @hash count = Conversation.count
expect(response).not_to be_success post :create, @hash
expect(response.body).to eq(I18n.t("javascripts.conversation.create.no_recipient")) expect(Conversation.count).to eq(count)
end end
end
context 'with nil contact' do it "does not create a message" do
before do count = Message.count
@hash = { post :create, @hash
:format => :js, expect(Message.count).to eq(count)
:conversation => { end
:subject => 'secret stuff',
:text => 'text debug'
},
:contact_ids => nil
}
end
it 'does not create a conversation' do
count = Conversation.count
post :create, @hash
expect(Conversation.count).to eq(count)
end
it 'does not create a message' do
count = Message.count
post :create, @hash
expect(Message.count).to eq(count)
end
it "responds with an error message" do
post :create, @hash
expect(response).not_to be_success
expect(response.body).to eq(I18n.t("javascripts.conversation.create.no_recipient"))
end end
end end
end end
describe '#show' do describe "#show" do
before do before do
hash = { hash = {
:author => alice.person, author: alice.person,
:participant_ids => [alice.contacts.first.person.id, alice.person.id], participant_ids: [alice.contacts.first.person.id, alice.person.id],
:subject => 'not spam', subject: "not spam",
:messages_attributes => [ {:author => alice.person, :text => 'cool stuff'} ] messages_attributes: [{author: alice.person, text: "cool stuff"}]
} }
@conversation = Conversation.create(hash) @conversation = Conversation.create(hash)
end end
it 'succeeds with json' do it "succeeds with json" do
get :show, :id => @conversation.id, :format => :json get :show, :id => @conversation.id, :format => :json
expect(response).to be_success expect(response).to be_success
expect(assigns[:conversation]).to eq(@conversation) expect(assigns[:conversation]).to eq(@conversation)
expect(response.body).to include @conversation.guid expect(response.body).to include @conversation.guid
end end
it 'redirects to index' do it "redirects to index" do
get :show, :id => @conversation.id get :show, :id => @conversation.id
expect(response).to redirect_to(conversations_path(:conversation_id => @conversation.id)) expect(response).to redirect_to(conversations_path(:conversation_id => @conversation.id))
end end

View file

@ -29,6 +29,9 @@ describe ConversationsController, :type => :controller do
get :index, :conversation_id => @conv1.id get :index, :conversation_id => @conv1.id
save_fixture(html_for("body"), "conversations_read") save_fixture(html_for("body"), "conversations_read")
get :new, modal: true
save_fixture(response.body, "conversations_modal")
end end
end end

View file

@ -277,4 +277,31 @@ describe("app.pages.Contacts", function(){
}); });
}); });
}); });
describe("showMessageModal", function() {
beforeEach(function() {
$("body").append("<div id='conversationModal'/>").append(spec.readFixture("conversations_modal"));
});
it("calls app.helpers.showModal", function() {
spyOn(app.helpers, "showModal");
this.view.showMessageModal();
expect(app.helpers.showModal);
});
it("app.views.ConversationsForm with correct parameters when modal is loaded", function() {
gon.conversationPrefill = [
{id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"},
{id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"},
{id: 3, name: "user@pod.tld", handle: "user@pod.tld"}
];
spyOn(app.views.ConversationsForm.prototype, "initialize");
this.view.showMessageModal();
$("#conversationModal").trigger("modal:loaded");
expect($("#conversationModal").length).toBe(1);
expect(app.views.ConversationsForm.prototype.initialize)
.toHaveBeenCalledWith({prefill: gon.conversationPrefill});
});
});
}); });

View file

@ -1,12 +1,133 @@
describe("app.views.ConversationsForm", function() { describe("app.views.ConversationsForm", function() {
beforeEach(function() { beforeEach(function() {
spec.loadFixture("conversations_read"); spec.loadFixture("conversations_read");
this.target = new app.views.ConversationsForm();
});
describe("initialize", function() {
it("initializes the conversation participants list", function() {
expect(this.target.conversationRecipients).toEqual([]);
});
it("initializes the search view", function() {
spyOn(app.views.SearchBase.prototype, "initialize");
this.target.initialize();
expect(app.views.SearchBase.prototype.initialize).toHaveBeenCalled();
expect(app.views.SearchBase.prototype.initialize.calls.argsFor(0)[0].customSearch).toBe(true);
expect(app.views.SearchBase.prototype.initialize.calls.argsFor(0)[0].autoselect).toBe(true);
expect(app.views.SearchBase.prototype.initialize.calls.argsFor(0)[0].remoteRoute).toEqual({
url: "/contacts",
extraParameters: "mutual=true"
});
expect(this.target.search).toBeDefined();
});
it("calls bindTypeaheadEvents", function() {
spyOn(app.views.ConversationsForm.prototype, "bindTypeaheadEvents");
this.target.initialize();
expect(app.views.ConversationsForm.prototype.bindTypeaheadEvents).toHaveBeenCalled();
});
it("calls prefill correctly", function() {
spyOn(app.views.ConversationsForm.prototype, "prefill");
this.target.initialize();
expect(app.views.ConversationsForm.prototype.prefill).not.toHaveBeenCalled();
this.target.initialize({prefill: {}});
expect(app.views.ConversationsForm.prototype.prefill).toHaveBeenCalledWith({});
});
});
describe("addRecipient", function() {
beforeEach(function() {
$("#conversation-new").removeClass("hidden");
$("#conversation-show").addClass("hidden");
});
it("add the participant", function() {
expect(this.target.conversationRecipients).toEqual([]);
this.target.addRecipient({name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect(this.target.conversationRecipients).toEqual([{name: "diaspora user", handle: "diaspora-user@pod.tld"}]);
});
it("call updateContactIdsListInput", function() {
spyOn(app.views.ConversationsForm.prototype, "updateContactIdsListInput");
this.target.addRecipient({name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect(app.views.ConversationsForm.prototype.updateContactIdsListInput).toHaveBeenCalled();
});
it("adds a recipient tag", function() {
expect($(".conversation-recipient-tag").length).toBe(0);
this.target.addRecipient({name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect($(".conversation-recipient-tag").length).toBe(1);
});
});
describe("prefill", function() {
beforeEach(function() {
this.prefills = [{name: "diaspora user"}, {name: "other diaspora user"}, {name: "user"}];
});
it("call addRecipient for each prefilled participant", function() {
spyOn(app.views.ConversationsForm.prototype, "addRecipient");
this.target.prefill(this.prefills);
expect(app.views.ConversationsForm.prototype.addRecipient).toHaveBeenCalledTimes(this.prefills.length);
var allArgsFlattened = app.views.ConversationsForm.prototype.addRecipient.calls.allArgs().map(function(arg) {
return arg[0];
});
expect(allArgsFlattened).toEqual(this.prefills);
});
});
describe("updateContactIdsListInput", function() {
beforeEach(function() {
this.target.conversationRecipients.push({id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"});
this.target.conversationRecipients
.push({id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"});
this.target.conversationRecipients.push({id: 3, name: "user@pod.tld", handle: "user@pod.tld"});
});
it("updates hidden input value", function() {
this.target.updateContactIdsListInput();
expect(this.target.contactsIdsListInput.val()).toBe("1,2,3");
});
it("calls app.views.SearchBase.ignorePersonForSuggestions() for each participant", function() {
spyOn(app.views.SearchBase.prototype, "ignorePersonForSuggestions");
this.target.updateContactIdsListInput();
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions).toHaveBeenCalledTimes(3);
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions.calls.argsFor(0)[0])
.toEqual({id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions.calls.argsFor(1)[0])
.toEqual({id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"});
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions.calls.argsFor(2)[0])
.toEqual({id: 3, name: "user@pod.tld", handle: "user@pod.tld"});
});
});
describe("bindTypeaheadEvents", function() {
it("calls onSuggestionSelection() when clicking on a result", function() {
spyOn(app.views.ConversationsForm.prototype, "onSuggestionSelection");
var event = $.Event("typeahead:select");
var person = {name: "diaspora user"};
this.target.typeaheadElement.trigger(event, [person]);
expect(app.views.ConversationsForm.prototype.onSuggestionSelection).toHaveBeenCalledWith(person);
});
});
describe("onSuggestionSelection", function() {
it("calls addRecipient, updateContactIdsListInput and $.fn.typeahead", function() {
spyOn(app.views.ConversationsForm.prototype, "addRecipient");
spyOn($.fn, "typeahead");
var person = {name: "diaspora user"};
this.target.onSuggestionSelection(person);
expect(app.views.ConversationsForm.prototype.addRecipient).toHaveBeenCalledWith(person);
expect($.fn.typeahead).toHaveBeenCalledWith("val", "");
});
}); });
describe("keyDown", function() { describe("keyDown", function() {
beforeEach(function() { beforeEach(function() {
this.submitCallback = jasmine.createSpy().and.returnValue(false); this.submitCallback = jasmine.createSpy().and.returnValue(false);
new app.views.ConversationsForm();
}); });
context("on new message form", function() { context("on new message form", function() {
@ -52,6 +173,54 @@ describe("app.views.ConversationsForm", function() {
}); });
}); });
describe("removeRecipient", function() {
beforeEach(function() {
this.target.addRecipient({id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"});
this.target.addRecipient({id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"});
this.target.addRecipient({id: 3, name: "user@pod.tld", handle: "user@pod.tld"});
});
it("removes the user from conversation recipients when clicking the tag's remove button", function() {
expect(this.target.conversationRecipients).toEqual([
{id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"},
{id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"},
{id: 3, name: "user@pod.tld", handle: "user@pod.tld"}
]);
$("[data-diaspora-handle='diaspora-user@pod.tld'] .remove").click();
expect(this.target.conversationRecipients).toEqual([
{id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"},
{id: 3, name: "user@pod.tld", handle: "user@pod.tld"}
]);
$("[data-diaspora-handle='other-diaspora-user@pod.tld'] .remove").click();
expect(this.target.conversationRecipients).toEqual([
{id: 3, name: "user@pod.tld", handle: "user@pod.tld"}
]);
$("[data-diaspora-handle='user@pod.tld'] .remove").click();
expect(this.target.conversationRecipients).toEqual([]);
});
it("removes the tag element when clicking the tag's remove button", function() {
expect($("[data-diaspora-handle='diaspora-user@pod.tld']").length).toBe(1);
$("[data-diaspora-handle='diaspora-user@pod.tld'] .remove").click();
expect($("[data-diaspora-handle='other-diaspora-user@pod.tld']").length).toBe(1);
$("[data-diaspora-handle='other-diaspora-user@pod.tld'] .remove").click();
expect($("[data-diaspora-handle='user@pod.tld']").length).toBe(1);
$("[data-diaspora-handle='user@pod.tld'] .remove").click();
});
it("calls updateContactIdsListInput", function() {
spyOn(app.views.ConversationsForm.prototype, "updateContactIdsListInput");
$("[data-diaspora-handle='diaspora-user@pod.tld'] .remove").click();
expect(app.views.ConversationsForm.prototype.updateContactIdsListInput).toHaveBeenCalled();
});
});
describe("conversationCreateSuccess", function() { describe("conversationCreateSuccess", function() {
it("is called when there was a successful ajax request for the conversation form", function() { it("is called when there was a successful ajax request for the conversation form", function() {
spyOn(app.views.ConversationsForm.prototype, "conversationCreateSuccess"); spyOn(app.views.ConversationsForm.prototype, "conversationCreateSuccess");

View file

@ -30,4 +30,30 @@ describe("app.views.ProfileHeader", function() {
})); }));
}); });
}); });
describe("showMessageModal", function() {
beforeEach(function() {
$("body").append("<div id='conversationModal'/>").append(spec.readFixture("conversations_modal"));
});
it("calls app.helpers.showModal", function() {
spyOn(app.helpers, "showModal");
this.view.showMessageModal();
expect(app.helpers.showModal);
});
it("app.views.ConversationsForm with correct parameterswhen modal is loaded", function() {
gon.conversationPrefill = [
{id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"},
{id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"},
{id: 3, name: "user@pod.tld", handle: "user@pod.tld"}
];
spyOn(app.views.ConversationsForm.prototype, "initialize");
this.view.showMessageModal();
$("#conversationModal").trigger("modal:loaded");
expect(app.views.ConversationsForm.prototype.initialize)
.toHaveBeenCalledWith({prefill: gon.conversationPrefill});
});
});
}); });

View file

@ -19,7 +19,7 @@ describe("app.views.PublisherMention", function() {
expect(call.args[0].typeaheadInput.selector).toBe("#publisher .typeahead-mention-box"); expect(call.args[0].typeaheadInput.selector).toBe("#publisher .typeahead-mention-box");
expect(call.args[0].customSearch).toBeTruthy(); expect(call.args[0].customSearch).toBeTruthy();
expect(call.args[0].autoselect).toBeTruthy(); expect(call.args[0].autoselect).toBeTruthy();
expect(call.args[0].remoteRoute).toBe("/contacts"); expect(call.args[0].remoteRoute).toEqual({url: "/contacts"});
}); });
it("calls bindTypeaheadEvents", function() { it("calls bindTypeaheadEvents", function() {

View file

@ -11,7 +11,7 @@ describe("app.views.Search", function() {
this.view = new app.views.Search({el: "#search_people_form"}); this.view = new app.views.Search({el: "#search_people_form"});
var call = app.views.SearchBase.prototype.initialize.calls.mostRecent(); var call = app.views.SearchBase.prototype.initialize.calls.mostRecent();
expect(call.args[0].typeaheadInput.selector).toBe("#search_people_form #q"); expect(call.args[0].typeaheadInput.selector).toBe("#search_people_form #q");
expect(call.args[0].remoteRoute).toBe("/search"); expect(call.args[0].remoteRoute).toEqual({url: "/search"});
}); });
it("binds typeahead:select", function() { it("binds typeahead:select", function() {