Refactor contacts page using pagination

This commit is contained in:
Steffen van Bergerem 2016-08-12 22:10:57 +02:00
parent d022e51a0c
commit 993f3d5ab0
No known key found for this signature in database
GPG key ID: 2F08F75F9525C7E0
11 changed files with 374 additions and 168 deletions

View file

@ -8,7 +8,6 @@ app.pages.Contacts = Backbone.View.extend({
"click #contacts_visibility_toggle" : "toggleContactVisibility",
"click #chat_privilege_toggle" : "toggleChatPrivilege",
"click #change_aspect_name" : "showAspectNameForm",
"keyup #contact_list_search" : "searchContactList",
"click .conversation_button": "showMessageModal",
"click #invitations-button": "showInvitationsModal"
},
@ -79,10 +78,6 @@ app.pages.Contacts = Backbone.View.extend({
$(".header > h3").show();
},
searchContactList: function(e) {
this.stream.search($(e.target).val());
},
showMessageModal: function(){
app.helpers.showModal("#conversationModal");
},

View file

@ -81,13 +81,14 @@ app.Router = Backbone.Router.extend({
).render();
},
contacts: function() {
contacts: function(params) {
app.aspect = new app.models.Aspect(gon.preloads.aspect);
this._loadContacts();
var stream = new app.views.ContactStream({
collection: app.contacts,
el: $(".stream.contacts #contact_stream")
el: $(".stream.contacts #contact_stream"),
urlParams: params
});
app.page = new app.pages.Contacts({stream: stream});

View file

@ -1,77 +1,79 @@
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
app.views.ContactStream = Backbone.View.extend({
initialize: function() {
this.itemCount = 0;
this.perPage = 25;
this.query = '';
this.resultList = this.collection.toArray();
initialize: function(opts) {
this.page = 1;
var throttledScroll = _.throttle(_.bind(this.infScroll, this), 200);
$(window).scroll(throttledScroll);
this.on('renderContacts', this.renderContacts, this);
this.on("fetchContacts", this.fetchContacts, this);
this.urlParams = opts.urlParams;
},
render: function() {
if( _.isEmpty(this.resultList) ) {
var content = document.createDocumentFragment();
content = '<div id="no_contacts" class="well">' +
' <h4>' +
Diaspora.I18n.t('contacts.search_no_results') +
' </h4>' +
'</div>';
this.$el.html(content);
} else {
this.$el.html('');
this.renderContacts();
}
this.fetchContacts();
},
renderContacts: function() {
fetchContacts: function() {
this.$el.addClass("loading");
var content = document.createDocumentFragment();
_.rest(_.first(this.resultList , this.itemCount + this.perPage), this.itemCount).forEach( function(item) {
var view = new app.views.Contact({model: item});
content.appendChild(view.render().el);
$("#paginate .loader").removeClass("hidden");
$.ajax(this._fetchUrl(), {
context: this
}).success(function(response) {
if (response.length === 0) {
this.onEmptyResponse();
} else {
this.appendContactViews(response);
this.page++;
}
});
},
var size = _.size(this.resultList);
if( this.itemCount + this.perPage >= size ){
this.itemCount = size;
this.off('renderContacts');
} else {
this.itemCount += this.perPage;
_fetchUrl: function() {
var url = Routes.contacts({format: "json", page: this.page});
if (this.urlParams) {
url += "&" + this.urlParams;
}
return url;
},
onEmptyResponse: function() {
if (this.collection.length === 0) {
var content = document.createDocumentFragment();
content = "<div id='no_contacts' class='well'>" +
" <h4>" +
Diaspora.I18n.t("contacts.search_no_results") +
" </h4>" +
"</div>";
this.$el.html(content);
}
this.off("fetchContacts");
this.$el.removeClass("loading");
$("#paginate .loader").addClass("hidden");
},
appendContactViews: function(contacts) {
var content = document.createDocumentFragment();
contacts.forEach(function(contactData) {
var contact = new app.models.Contact(contactData);
this.collection.add(contact);
var view = new app.views.Contact({model: contact});
content.appendChild(view.render().el);
}.bind(this));
this.$el.append(content);
this.$el.removeClass("loading");
},
search: function(query) {
query = query.trim();
if( query || this.query ) {
this.off('renderContacts');
this.on('renderContacts', this.renderContacts, this);
this.itemCount = 0;
if( query ) {
this.query = query;
var regex = new RegExp(query,'i');
this.resultList = this.collection.filter(function(contact) {
return regex.test(contact.get('person').name) ||
regex.test(contact.get('person').diaspora_id);
});
} else {
this.resultList = this.collection.toArray();
this.query = '';
}
this.render();
}
$("#paginate .loader").addClass("hidden");
},
infScroll: function() {
if( this.$el.hasClass('loading') ) return;
if (this.$el.hasClass("loading")) {
return;
}
var distanceTop = $(window).height() + $(window).scrollTop(),
distanceBottom = $(document).height() - distanceTop;
if(distanceBottom < 300) this.trigger('renderContacts');
if (distanceBottom < 300) {
this.trigger("fetchContacts");
}
}
});
// @license-end

View file

@ -10,8 +10,13 @@
margin-bottom: 11px;
margin-top: 11px;
}
}
.aspect-controls { margin: 7px -10px 7px 0; }
.stream.contacts .aspect-controls {
margin-bottom: 7px;
margin-left: 30px;
margin-right: -10px;
margin-top: 7px;
}
}
@ -23,7 +28,7 @@
display: none;
}
#contact_list_search {
margin: 11px 30px 0 0;
margin: 11px 0 0;
width: 150px;
&:focus { width: 250px; }
}

View file

@ -14,9 +14,13 @@ class ContactsController < ApplicationController
# Used by the mobile site
format.mobile { set_up_contacts_mobile }
# Used for mentions in the publisher
# Used for mentions in the publisher and pagination on the contacts page
format.json {
@people = Person.search(params[:q], current_user, only_contacts: true).limit(15)
@people = if params[:q].present?
Person.search(params[:q], current_user, only_contacts: true).limit(15)
else
set_up_contacts_json
end
render json: @people
}
end
@ -30,30 +34,54 @@ class ContactsController < ApplicationController
private
def set_up_contacts
type = params[:set].presence
type ||= "by_aspect" if params[:a_id].present?
type ||= "receiving"
if params[:a_id].present?
@aspect = current_user.aspects.find(params[:a_id])
gon.preloads[:aspect] = AspectPresenter.new(@aspect).as_json
end
@contacts_size = current_user.contacts.size
end
@contacts = contacts_by_type(type)
@contacts_size = @contacts.length
gon.preloads[:contacts] = @contacts.map {|c| ContactPresenter.new(c, current_user).full_hash_with_person }
def set_up_contacts_json
type = params[:set].presence
if params[:a_id].present?
type ||= "by_aspect"
@aspect = current_user.aspects.find(params[:a_id])
end
type ||= "receiving"
contacts_by_type(type).paginate(page: params[:page], per_page: 25)
.map {|c| ContactPresenter.new(c, current_user).full_hash_with_person }
end
def contacts_by_type(type)
case type
order = ["profiles.first_name ASC", "profiles.last_name ASC", "profiles.diaspora_handle ASC"]
contacts = case type
when "all"
order.unshift "receiving DESC"
current_user.contacts
when "only_sharing"
current_user.contacts.only_sharing
when "receiving"
current_user.contacts.receiving
when "by_aspect"
@aspect = current_user.aspects.find(params[:a_id])
gon.preloads[:aspect] = AspectPresenter.new(@aspect).as_json
current_user.contacts
order.unshift "contact_id IS NOT NULL DESC"
contacts_by_aspect(@aspect.id)
else
raise ArgumentError, "unknown type #{type}"
end
contacts.includes(person: :profile)
.order(order)
end
def contacts_by_aspect(aspect_id)
contacts = current_user.contacts.arel_table
aspect_memberships = AspectMembership.arel_table
current_user.contacts.joins(
contacts.outer_join(aspect_memberships).on(
aspect_memberships[:aspect_id].eq(aspect_id).and(
aspect_memberships[:contact_id].eq(contacts[:id])
)
).join_sources
)
end
def set_up_contacts_mobile

View file

@ -20,7 +20,11 @@
= link_to @aspect, method: "delete", data: { confirm: t("aspects.edit.confirm_remove_aspect") }, class: "delete contacts_button", id: "delete_aspect" do
%i.entypo-trash.contacts-header-icon{title: t("delete")}
.pull-right.contact-list-search
= search_field_tag :contact_search, "", id: "contact_list_search", class: "search-query form-control", placeholder: t("contacts.index.user_search")
%form#contact-search-form{role: "search", method: "get", action: "/search"}
= search_field_tag :q, "",
id: "contact_list_search",
class: "search-query form-control",
placeholder: t("contacts.index.user_search")
%h3
%span#aspect_name
= @aspect.name
@ -32,6 +36,13 @@
= aspect.submit t('aspects.edit.update'), 'data-disable-with' => t('aspects.edit.updating'), class: "btn btn-default"
- else
.pull-right.contact-list-search
%form#contact-search-form{role: "search", method: "get", action: "/search"}
= search_field_tag :q, "",
id: "contact_list_search",
class: "search-query form-control",
placeholder: t("contacts.index.user_search")
%h3
- case params["set"]
- when "only_sharing"

View file

@ -29,6 +29,10 @@
.btn.btn-link{ 'data-toggle' => 'modal' }
= t('invitations.new.invite_someone_to_join')
#paginate
%span.loader.hidden
.spinner
-if @aspect
#new_conversation_pane
= render 'shared/modal',

View file

@ -24,51 +24,97 @@ describe ContactsController, :type => :controller do
expect(response).to be_success
end
it "assigns contacts" do
it "doesn't assign contacts" do
get :index
contacts = assigns(:contacts)
expect(contacts.to_set).to eq(bob.contacts.to_set)
end
it "shows only contacts a user is sharing with" do
contact = bob.contacts.first
contact.update_attributes(:sharing => false)
get :index
contacts = assigns(:contacts)
expect(contacts.to_set).to eq(bob.contacts.receiving.to_set)
end
it "shows all contacts (sharing and receiving)" do
contact = bob.contacts.first
contact.update_attributes(:sharing => false)
get :index, :set => "all"
contacts = assigns(:contacts)
expect(contacts.to_set).to eq(bob.contacts.to_set)
expect(contacts).to be_nil
end
end
context "format json" do
before do
@person1 = FactoryGirl.create(:person)
bob.share_with(@person1, bob.aspects.first)
@person2 = FactoryGirl.create(:person)
context "for the contacts search" do
before do
@person1 = FactoryGirl.create(:person)
bob.share_with(@person1, bob.aspects.first)
@person2 = FactoryGirl.create(:person)
end
it "succeeds" do
get :index, q: @person1.first_name, format: "json"
expect(response).to be_success
end
it "responds with json" do
get :index, q: @person1.first_name, format: "json"
expect(response.body).to eq([@person1].to_json)
end
it "only returns contacts" do
get :index, q: @person2.first_name, format: "json"
expect(response.body).to eq([].to_json)
end
end
it "succeeds" do
get :index, q: @person1.first_name, format: "json"
expect(response).to be_success
end
context "for pagination on the contacts page" do
context "without parameters" do
it "returns contacts" do
get :index, format: "json", page: "1"
contact_ids = JSON.parse(response.body).map {|c| c["id"] }
expect(contact_ids.to_set).to eq(bob.contacts.map(&:id).to_set)
end
it "responds with json" do
get :index, q: @person1.first_name, format: "json"
expect(response.body).to eq([@person1].to_json)
end
it "returns only contacts which are receiving (the user is sharing with them)" do
contact = bob.contacts.first
contact.update_attributes(receiving: false)
it "only returns contacts" do
get :index, q: @person2.first_name, format: "json"
expect(response.body).to eq([].to_json)
get :index, format: "json", page: "1"
contact_ids = JSON.parse(response.body).map {|c| c["id"] }
expect(contact_ids.to_set).to eq(bob.contacts.receiving.map(&:id).to_set)
expect(contact_ids).not_to include(contact.id)
end
end
context "set: all" do
before do
contact = bob.contacts.first
contact.update_attributes(receiving: false)
end
it "returns all contacts (sharing and receiving)" do
get :index, format: "json", page: "1", set: "all"
contact_ids = JSON.parse(response.body).map {|c| c["id"] }
expect(contact_ids.to_set).to eq(bob.contacts.map(&:id).to_set)
end
it "sorts contacts by receiving status" do
get :index, format: "json", page: "1", set: "all"
contact_ids = JSON.parse(response.body).map {|c| c["id"] }
expect(contact_ids).to eq(bob.contacts.order("receiving DESC").map(&:id))
expect(contact_ids.last).to eq(bob.contacts.first.id)
end
end
context "with an aspect id" do
before do
@aspect = bob.aspects.create(name: "awesome contacts")
@person = FactoryGirl.create(:person)
bob.share_with(@person, @aspect)
end
it "returns all contacts" do
get :index, format: "json", a_id: @aspect.id, page: "1"
contact_ids = JSON.parse(response.body).map {|c| c["id"] }
expect(contact_ids.to_set).to eq(bob.contacts.map(&:id).to_set)
end
it "sorts contacts by aspect memberships" do
get :index, format: "json", a_id: @aspect.id, page: "1"
expect(JSON.parse(response.body).first["person"]["id"]).to eq(@person.id)
get :index, format: "json", a_id: bob.aspects.first.id, page: "1"
expect(JSON.parse(response.body).first["person"]["id"]).not_to eq(@person.id)
end
end
end
end
end

View file

@ -17,7 +17,11 @@ describe ContactsController, :type => :controller do
it "generates the aspects_manage fixture", :fixture => true do
get :index, :a_id => @aspect.id
save_fixture(html_for("body"), "aspects_manage")
save_fixture(controller.gon.preloads[:contacts].to_json, "aspects_manage_contacts_json")
end
it "generates the aspects_manage_contacts_json fixture", fixture: true do
get :index, format: :json, a_id: @aspect.id, page: "1"
save_fixture(response.body, "aspects_manage_contacts_json")
end
it "generates the contacts_json fixture", :fixture => true do

View file

@ -88,19 +88,6 @@ describe("app.pages.Contacts", function(){
});
});
context('search contact list', function() {
beforeEach(function() {
this.searchinput = $('#contact_list_search');
});
it('calls stream.search', function() {
this.view.stream.search = jasmine.createSpy();
this.searchinput.val("Username");
this.searchinput.trigger('keyup');
expect(this.view.stream.search).toHaveBeenCalledWith("Username");
});
});
describe("updateBadgeCount", function() {
it("increases the badge count of an aspect", function() {
var aspect = $("#aspect_nav .aspect").eq(0);

View file

@ -2,76 +2,199 @@ describe("app.views.ContactStream", function() {
beforeEach(function() {
loginAs({name: "alice", avatar : {small : "http://avatar.com/photo.jpg"}});
spec.loadFixture("aspects_manage");
this.contacts = new app.collections.Contacts($.parseJSON(spec.readFixture("contacts_json")));
app.aspect = new app.models.Aspect(this.contacts.first().get('aspect_memberships')[0].aspect);
this.contacts = new app.collections.Contacts();
this.contactsData = $.parseJSON(spec.readFixture("contacts_json"));
app.aspect = new app.models.Aspect(this.contactsData[0].aspect_memberships[0].aspect);
this.view = new app.views.ContactStream({
collection : this.contacts,
el: $('.stream.contacts #contact_stream')
el: $(".stream.contacts #contact_stream"),
urlParams: "set=all"
});
this.view.perPage=1;
//clean the page
this.view.$el.html('');
});
describe("initialize", function() {
it("binds an infinite scroll listener", function() {
spyOn($.fn, "scroll");
new app.views.ContactStream({collection : this.contacts});
new app.views.ContactStream({collection: this.contacts});
expect($.fn.scroll).toHaveBeenCalled();
});
it("binds 'fetchContacts'", function() {
spyOn(app.views.ContactStream.prototype, "fetchContacts");
this.view = new app.views.ContactStream({collection: this.contacts});
this.view.trigger("fetchContacts");
expect(app.views.ContactStream.prototype.fetchContacts).toHaveBeenCalled();
});
it("sets the current page for pagination to 1", function() {
expect(this.view.page).toBe(1);
});
it("sets urlParams to the given value", function() {
expect(this.view.urlParams).toBe("set=all");
});
});
describe("search", function() {
it("filters the contacts", function() {
describe("render", function() {
it("calls fetchContacts", function() {
spyOn(this.view, "fetchContacts");
this.view.render();
expect(this.view.$el.html()).toContain("alice");
this.view.search("eve");
expect(this.view.$el.html()).not.toContain("alice");
expect(this.view.$el.html()).toContain("eve");
expect(this.view.fetchContacts).toHaveBeenCalled();
});
});
describe("fetchContacts", function() {
it("adds the loading class", function() {
expect(this.view.$el).not.toHaveClass("loading");
this.view.fetchContacts();
expect(this.view.$el).toHaveClass("loading");
});
it("displays the loading spinner", function() {
expect($("#paginate .loader")).toHaveClass("hidden");
this.view.fetchContacts();
expect($("#paginate .loader")).not.toHaveClass("hidden");
});
it("calls $.ajax with the URL given by _fetchUrl", function() {
spyOn(this.view, "_fetchUrl").and.returnValue("/myAwesomeFetchUrl?foo=bar");
this.view.fetchContacts();
expect(jasmine.Ajax.requests.mostRecent().url).toBe("/myAwesomeFetchUrl?foo=bar");
});
it("calls onEmptyResponse on an empty response", function() {
spyOn(this.view, "onEmptyResponse");
this.view.fetchContacts();
jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: JSON.stringify([])});
expect(this.view.onEmptyResponse).toHaveBeenCalled();
});
it("calls appendContactViews on a non-empty response", function() {
spyOn(this.view, "appendContactViews");
this.view.fetchContacts();
jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: JSON.stringify(this.contactsData)});
expect(this.view.appendContactViews).toHaveBeenCalledWith(this.contactsData);
});
it("increases the current page on a non-empty response", function() {
this.view.page = 42;
this.view.fetchContacts();
jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: JSON.stringify(this.contactsData)});
expect(this.view.page).toBe(43);
});
});
describe("_fetchUrl", function() {
it("returns the correct URL to fetch contacts", function() {
this.view.page = 15;
this.view.urlParams = undefined;
expect(this.view._fetchUrl()).toBe("/contacts.json?page=15");
});
it("appends urlParams if those are set", function() {
this.view.page = 23;
expect(this.view._fetchUrl()).toBe("/contacts.json?page=23&set=all");
});
});
describe("onEmptyResponse", function() {
context("with an empty collection", function() {
it("adds a 'no contacts' div", function() {
this.view.onEmptyResponse();
expect(this.view.$("#no_contacts").text().trim()).toBe(Diaspora.I18n.t("contacts.search_no_results"));
});
it("hides the loading spinner", function() {
this.view.$el.addClass("loading");
$("#paginate .loader").removeClass("hidden");
this.view.onEmptyResponse();
expect(this.view.$el).not.toHaveClass("loading");
expect($("#paginate .loader")).toHaveClass("hidden");
});
it("unbinds 'fetchContacts'", function() {
spyOn(this.view, "off");
this.view.onEmptyResponse();
expect(this.view.off).toHaveBeenCalledWith("fetchContacts");
});
});
context("with a non-empty collection", function() {
beforeEach(function() {
this.view.collection.add(factory.contact());
});
it("adds no 'no contacts' div", function() {
this.view.onEmptyResponse();
expect(this.view.$("#no_contacts").length).toBe(0);
});
it("hides the loading spinner", function() {
this.view.$el.addClass("loading");
$("#paginate .loader").removeClass("hidden");
this.view.onEmptyResponse();
expect(this.view.$el).not.toHaveClass("loading");
expect($("#paginate .loader")).toHaveClass("hidden");
});
it("unbinds 'fetchContacts'", function() {
spyOn(this.view, "off");
this.view.onEmptyResponse();
expect(this.view.off).toHaveBeenCalledWith("fetchContacts");
});
});
});
describe("appendContactViews", function() {
it("hides the loading spinner", function() {
this.view.$el.addClass("loading");
$("#paginate .loader").removeClass("hidden");
this.view.appendContactViews(this.contactsData);
expect(this.view.$el).not.toHaveClass("loading");
expect($("#paginate .loader")).toHaveClass("hidden");
});
it("adds all contacts to an empty collection", function() {
expect(this.view.collection.length).toBe(0);
this.view.appendContactViews(this.contactsData);
expect(this.view.collection.length).toBe(this.contactsData.length);
expect(this.view.collection.pluck("id")).toEqual(_.pluck(this.contactsData, "id"));
});
it("appends contacts to an existing collection", function() {
this.view.collection.add(this.contactsData[0]);
expect(this.view.collection.length).toBe(1);
this.view.appendContactViews(_.rest(this.contactsData));
expect(this.view.collection.length).toBe(this.contactsData.length);
expect(this.view.collection.pluck("id")).toEqual(_.pluck(this.contactsData, "id"));
});
it("renders all added contacts", function() {
expect(this.view.$(".stream_element.contact").length).toBe(0);
this.view.appendContactViews(this.contactsData);
expect(this.view.$(".stream_element.contact").length).toBe(this.contactsData.length);
});
it("appends contacts to an existing contact list", function() {
this.view.appendContactViews([this.contactsData[0]]);
expect(this.view.$(".stream_element.contact").length).toBe(1);
this.view.appendContactViews(_.rest(this.contactsData));
expect(this.view.$(".stream_element.contact").length).toBe(this.contactsData.length);
});
});
describe("infScroll", function() {
beforeEach(function() {
this.view.off("renderContacts");
this.view.off("fetchContacts");
this.fn = jasmine.createSpy();
this.view.on("renderContacts", this.fn);
this.view.on("fetchContacts", this.fn);
spyOn($.fn, "height").and.returnValue(0);
spyOn($.fn, "scrollTop").and.returnValue(100);
});
it("triggers renderContacts when the user is at the bottom of the page", function() {
it("triggers fetchContacts when the user is at the bottom of the page", function() {
this.view.infScroll();
expect(this.fn).toHaveBeenCalled();
});
});
describe("render", function() {
beforeEach(function() {
spyOn(this.view, "renderContacts");
});
it("calls renderContacts", function() {
this.view.render();
expect(this.view.renderContacts).toHaveBeenCalled();
});
});
describe("renderContacts", function() {
beforeEach(function() {
this.view.off("renderContacts");
this.view.renderContacts();
});
it("renders perPage contacts", function() {
expect(this.view.$el.find('.stream_element.contact').length).toBe(1);
});
it("renders more contacts when called a second time", function() {
this.view.renderContacts();
expect(this.view.$el.find('.stream_element.contact').length).toBe(2);
});
});
});