From af331bfb30bc094432fe2d8b4cab4aa00b9fd23a Mon Sep 17 00:00:00 2001 From: Augier <contact@c-henry.fr> Date: Mon, 8 Aug 2016 16:56:02 +0200 Subject: [PATCH] Add collection to app.views.NotificationDropdown and app.views.Notifications closes #6952 --- Changelog.md | 2 + app/assets/javascripts/app/app.js | 1 + .../app/collections/notifications.js | 118 +++++++++ .../javascripts/app/models/notification.js | 69 +++++ app/assets/javascripts/app/router.js | 2 +- .../javascripts/app/views/header_view.js | 10 +- .../app/views/notification_dropdown_view.js | 71 ++--- .../app/views/notifications_view.js | 117 ++++---- .../helpers/browser_notification.js | 22 ++ app/assets/templates/header_tpl.jst.hbs | 9 +- app/controllers/notifications_controller.rb | 13 +- app/views/notifications/index.html.haml | 3 +- config/locales/javascript/javascript.en.yml | 3 + .../jasmine_fixtures/notifications_spec.rb | 2 + .../notifications_controller_spec.rb | 17 +- .../notifications_collection_spec.js | 249 ++++++++++++++++++ .../app/models/notification_spec.js | 85 ++++++ .../javascripts/app/views/header_view_spec.js | 1 + .../views/notification_dropdown_view_spec.js | 143 ++++------ .../app/views/notifications_view_spec.js | 220 +++++++++++----- 20 files changed, 864 insertions(+), 293 deletions(-) create mode 100644 app/assets/javascripts/app/collections/notifications.js create mode 100644 app/assets/javascripts/app/models/notification.js create mode 100644 app/assets/javascripts/helpers/browser_notification.js create mode 100644 spec/javascripts/app/collections/notifications_collection_spec.js create mode 100644 spec/javascripts/app/models/notification_spec.js diff --git a/Changelog.md b/Changelog.md index 1bbd260aa5..ac93a6c507 100644 --- a/Changelog.md +++ b/Changelog.md @@ -19,6 +19,8 @@ * Add a dark color theme [#7152](https://github.com/diaspora/diaspora/pull/7152) * Added setting for custom changelog URL [#7166](https://github.com/diaspora/diaspora/pull/7166) * Show more information of recipients on conversation creation [#7129](https://github.com/diaspora/diaspora/pull/7129) +* Update notifications every 5 minutes and when opening the notification dropdown [#6952](https://github.com/diaspora/diaspora/pull/6952) +* Show browser notifications when receiving new unread notifications [#6952](https://github.com/diaspora/diaspora/pull/6952) # 0.6.1.0 diff --git a/app/assets/javascripts/app/app.js b/app/assets/javascripts/app/app.js index 1046bd66ab..d300be7e6d 100644 --- a/app/assets/javascripts/app/app.js +++ b/app/assets/javascripts/app/app.js @@ -90,6 +90,7 @@ var app = { setupHeader: function() { if(app.currentUser.authenticated()) { + app.notificationsCollection = new app.collections.Notifications(); app.header = new app.views.Header(); $("header").prepend(app.header.el); app.header.render(); diff --git a/app/assets/javascripts/app/collections/notifications.js b/app/assets/javascripts/app/collections/notifications.js new file mode 100644 index 0000000000..0bb4a2f977 --- /dev/null +++ b/app/assets/javascripts/app/collections/notifications.js @@ -0,0 +1,118 @@ +app.collections.Notifications = Backbone.Collection.extend({ + model: app.models.Notification, + // URL parameter + /* eslint-disable camelcase */ + url: Routes.notifications({per_page: 10, page: 1}), + /* eslint-enable camelcase */ + page: 2, + perPage: 5, + unreadCount: 0, + unreadCountByType: {}, + timeout: 300000, // 5 minutes + + initialize: function() { + this.pollNotifications(); + + setTimeout(function() { + setInterval(this.pollNotifications.bind(this), this.timeout); + }.bind(this), this.timeout); + + Diaspora.BrowserNotification.requestPermission(); + }, + + pollNotifications: function() { + var unreadCountBefore = this.unreadCount; + this.fetch(); + + this.once("finishedLoading", function() { + if (unreadCountBefore < this.unreadCount) { + Diaspora.BrowserNotification.spawnNotification( + Diaspora.I18n.t("notifications.new_notifications", {count: this.unreadCount})); + } + }, this); + }, + + fetch: function(options) { + options = options || {}; + options.remove = false; + options.merge = true; + options.parse = true; + Backbone.Collection.prototype.fetch.apply(this, [options]); + }, + + fetchMore: function() { + var hasMoreNotifications = (this.page * this.perPage) <= this.length; + // There are more notifications to load on the current page + if (hasMoreNotifications) { + this.page++; + // URL parameter + /* eslint-disable camelcase */ + var route = Routes.notifications({per_page: this.perPage, page: this.page}); + /* eslint-enable camelcase */ + this.fetch({url: route, pushBack: true}); + } + }, + + /** + * Adds new models to the collection at the end or at the beginning of the collection and + * then fires an event for each model of the collection. It will fire a different event + * based on whether the models were added at the end (typically when the scroll triggers to load more + * notifications) or at the beginning (new notifications have been added to the front of the list). + */ + set: function(items, options) { + options = options || {}; + options.at = options.pushBack ? this.length : 0; + + // Retreive back the new created models + var models = []; + var accu = function(model) { models.push(model); }; + this.on("add", accu); + Backbone.Collection.prototype.set.apply(this, [items, options]); + this.off("add", accu); + + if (options.pushBack) { + models.forEach(function(model) { this.trigger("pushBack", model); }.bind(this)); + } else { + // Fires events in the reverse order so that the first event is prepended in first position + models.reverse(); + models.forEach(function(model) { this.trigger("pushFront", model); }.bind(this)); + } + this.trigger("finishedLoading"); + }, + + parse: function(response) { + this.unreadCount = response.unread_count; + this.unreadCountByType = response.unread_count_by_type; + + return _.map(response.notification_list, function(item) { + /* eslint-disable new-cap */ + var model = new this.model(item); + /* eslint-enable new-cap */ + model.on("change:unread", this.onChangedUnreadStatus.bind(this)); + return model; + }.bind(this)); + }, + + setAllRead: function() { + this.forEach(function(model) { model.setRead(); }); + }, + + setRead: function(guid) { + this.find(function(model) { return model.guid === guid; }).setRead(); + }, + + setUnread: function(guid) { + this.find(function(model) { return model.guid === guid; }).setUnread(); + }, + + onChangedUnreadStatus: function(model) { + if (model.get("unread") === true) { + this.unreadCount++; + this.unreadCountByType[model.get("type")]++; + } else { + this.unreadCount = Math.max(this.unreadCount - 1, 0); + this.unreadCountByType[model.get("type")] = Math.max(this.unreadCountByType[model.get("type")] - 1, 0); + } + this.trigger("update"); + } +}); diff --git a/app/assets/javascripts/app/models/notification.js b/app/assets/javascripts/app/models/notification.js new file mode 100644 index 0000000000..b2e342116a --- /dev/null +++ b/app/assets/javascripts/app/models/notification.js @@ -0,0 +1,69 @@ +app.models.Notification = Backbone.Model.extend({ + constructor: function(attributes, options) { + options = options || {}; + options.parse = true; + Backbone.Model.apply(this, [attributes, options]); + this.guid = this.get("id"); + }, + + /** + * Flattens the notification object returned by the server. + * + * The server returns an object that looks like: + * + * { + * "reshared": { + * "id": 45, + * "target_type": "Post", + * "target_id": 11, + * "recipient_id": 1, + * "unread": true, + * "created_at": "2015-10-27T19:56:30.000Z", + * "updated_at": "2015-10-27T19:56:30.000Z", + * "note_html": <html/> + * }, + * "type": "reshared" + * } + * + * The returned object looks like: + * + * { + * "type": "reshared", + * "id": 45, + * "target_type": "Post", + * "target_id": 11, + * "recipient_id": 1, + * "unread": true, + * "created_at": "2015-10-27T19:56:30.000Z", + * "updated_at": "2015-10-27T19:56:30.000Z", + * "note_html": <html/>, + * } + */ + parse: function(response) { + var result = {type: response.type}; + result = $.extend(result, response[result.type]); + return result; + }, + + setRead: function() { + this.setUnreadStatus(false); + }, + + setUnread: function() { + this.setUnreadStatus(true); + }, + + setUnreadStatus: function(state) { + if (this.get("unread") !== state) { + $.ajax({ + url: Routes.notification(this.guid), + /* eslint-disable camelcase */ + data: {set_unread: state}, + /* eslint-enable camelcase */ + type: "PUT", + context: this, + success: function() { this.set("unread", state); } + }); + } + } +}); diff --git a/app/assets/javascripts/app/router.js b/app/assets/javascripts/app/router.js index 1884bb880f..ed8ec9d9a0 100644 --- a/app/assets/javascripts/app/router.js +++ b/app/assets/javascripts/app/router.js @@ -139,7 +139,7 @@ app.Router = Backbone.Router.extend({ notifications: function() { this._loadContacts(); this.renderAspectMembershipDropdowns($(document)); - new app.views.Notifications({el: "#notifications_container"}); + new app.views.Notifications({el: "#notifications_container", collection: app.notificationsCollection}); }, peopleSearch: function() { diff --git a/app/assets/javascripts/app/views/header_view.js b/app/assets/javascripts/app/views/header_view.js index 5b682c3b34..496cd83d34 100644 --- a/app/assets/javascripts/app/views/header_view.js +++ b/app/assets/javascripts/app/views/header_view.js @@ -12,12 +12,12 @@ app.views.Header = app.views.Base.extend({ }); }, - postRenderTemplate: function(){ - new app.views.Notifications({ el: "#notification-dropdown" }); - this.notificationDropdown = new app.views.NotificationDropdown({ el: "#notification-dropdown" }); - new app.views.Search({ el: "#header-search-form" }); + postRenderTemplate: function() { + new app.views.Notifications({el: "#notification-dropdown", collection: app.notificationsCollection}); + new app.views.NotificationDropdown({el: "#notification-dropdown", collection: app.notificationsCollection}); + new app.views.Search({el: "#header-search-form"}); }, - menuElement: function(){ return this.$("ul.dropdown"); }, + menuElement: function() { return this.$("ul.dropdown"); } }); // @license-end diff --git a/app/assets/javascripts/app/views/notification_dropdown_view.js b/app/assets/javascripts/app/views/notification_dropdown_view.js index a44556c9ad..a72f1e8f1d 100644 --- a/app/assets/javascripts/app/views/notification_dropdown_view.js +++ b/app/assets/javascripts/app/views/notification_dropdown_view.js @@ -6,16 +6,21 @@ app.views.NotificationDropdown = app.views.Base.extend({ }, initialize: function(){ - $(document.body).click($.proxy(this.hideDropdown, this)); + $(document.body).click(this.hideDropdown.bind(this)); - this.notifications = []; - this.perPage = 5; - this.hasMoreNotifs = true; this.badge = this.$el; this.dropdown = $("#notification-dropdown"); this.dropdownNotifications = this.dropdown.find(".notifications"); this.ajaxLoader = this.dropdown.find(".ajax-loader"); this.perfectScrollbarInitialized = false; + this.dropdownNotifications.scroll(this.dropdownScroll.bind(this)); + this.bindCollectionEvents(); + }, + + bindCollectionEvents: function() { + this.collection.on("pushFront", this.onPushFront.bind(this)); + this.collection.on("pushBack", this.onPushBack.bind(this)); + this.collection.on("finishedLoading", this.finishLoading.bind(this)); }, toggleDropdown: function(evt){ @@ -31,12 +36,11 @@ app.views.NotificationDropdown = app.views.Base.extend({ }, showDropdown: function(){ - this.resetParams(); this.ajaxLoader.show(); this.dropdown.addClass("dropdown-open"); this.updateScrollbar(); this.dropdownNotifications.addClass("loading"); - this.getNotifications(); + this.collection.fetch(); }, hideDropdown: function(evt){ @@ -50,40 +54,18 @@ app.views.NotificationDropdown = app.views.Base.extend({ dropdownScroll: function(){ var isLoading = ($(".loading").length === 1); - if (this.isBottom() && this.hasMoreNotifs && !isLoading){ + if (this.isBottom() && !isLoading) { this.dropdownNotifications.addClass("loading"); - this.getNotifications(); + this.collection.fetchMore(); } }, - getParams: function(){ - if(this.notifications.length === 0){ return{ per_page: 10, page: 1 }; } - else{ return{ per_page: this.perPage, page: this.nextPage }; } - }, - - resetParams: function(){ - this.notifications.length = 0; - this.hasMoreNotifs = true; - delete this.nextPage; - }, - isBottom: function(){ var bottom = this.dropdownNotifications.prop("scrollHeight") - this.dropdownNotifications.height(); var currentPosition = this.dropdownNotifications.scrollTop(); return currentPosition + 50 >= bottom; }, - getNotifications: function(){ - var self = this; - $.getJSON(Routes.notifications(this.getParams()), function(notifications){ - $.each(notifications, function(){ self.notifications.push(this); }); - self.hasMoreNotifs = notifications.length >= self.perPage; - if(self.nextPage){ self.nextPage++; } - else { self.nextPage = 3; } - self.renderNotifications(); - }); - }, - hideAjaxLoader: function(){ var self = this; this.ajaxLoader.find(".spinner").fadeTo(200, 0, function(){ @@ -93,28 +75,23 @@ app.views.NotificationDropdown = app.views.Base.extend({ }); }, - renderNotifications: function(){ - var self = this; - this.dropdownNotifications.find(".media.stream-element").remove(); - $.each(self.notifications, function(index, notifications){ - $.each(notifications, function(index, notification){ - if($.inArray(notification, notifications) === -1){ - var node = self.dropdownNotifications.append(notification.note_html); - $(node).find(".unread-toggle .entypo-eye").tooltip("destroy").tooltip(); - $(node).find(self.avatars.selector).error(self.avatars.fallback); - } - }); - }); + onPushBack: function(notification) { + var node = this.dropdownNotifications.append(notification.get("note_html")); + $(node).find(".unread-toggle .entypo-eye").tooltip("destroy").tooltip(); + $(node).find(this.avatars.selector).error(this.avatars.fallback); + }, - this.hideAjaxLoader(); + onPushFront: function(notification) { + var node = this.dropdownNotifications.prepend(notification.get("note_html")); + $(node).find(".unread-toggle .entypo-eye").tooltip("destroy").tooltip(); + $(node).find(this.avatars.selector).error(this.avatars.fallback); + }, + finishLoading: function() { app.helpers.timeago(this.dropdownNotifications); - this.updateScrollbar(); + this.hideAjaxLoader(); this.dropdownNotifications.removeClass("loading"); - this.dropdownNotifications.scroll(function(){ - self.dropdownScroll(); - }); }, updateScrollbar: function() { diff --git a/app/assets/javascripts/app/views/notifications_view.js b/app/assets/javascripts/app/views/notifications_view.js index 08400742ec..3ae156348b 100644 --- a/app/assets/javascripts/app/views/notifications_view.js +++ b/app/assets/javascripts/app/views/notifications_view.js @@ -1,96 +1,85 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later app.views.Notifications = Backbone.View.extend({ - events: { - "click .unread-toggle" : "toggleUnread", - "click #mark_all_read_link": "markAllRead" + "click .unread-toggle": "toggleUnread", + "click #mark-all-read-link": "markAllRead" }, initialize: function() { $(".unread-toggle .entypo-eye").tooltip(); app.helpers.timeago($(document)); + this.bindCollectionEvents(); + }, + + bindCollectionEvents: function() { + this.collection.on("change", this.onChangedUnreadStatus.bind(this)); + this.collection.on("update", this.updateView.bind(this)); }, toggleUnread: function(evt) { var note = $(evt.target).closest(".stream-element"); var unread = note.hasClass("unread"); var guid = note.data("guid"); - if (unread){ this.setRead(guid); } - else { this.setUnread(guid); } - }, - - getAllUnread: function() { return $(".media.stream-element.unread"); }, - - setRead: function(guid) { this.setUnreadStatus(guid, false); }, - - setUnread: function(guid){ this.setUnreadStatus(guid, true); }, - - setUnreadStatus: function(guid, state){ - $.ajax({ - url: "/notifications/" + guid, - data: { set_unread: state }, - type: "PUT", - context: this, - success: this.clickSuccess - }); + if (unread) { + this.collection.setRead(guid); + } else { + this.collection.setUnread(guid); + } }, - clickSuccess: function(data) { - var guid = data.guid; - var type = $(".stream-element[data-guid=" + guid + "]").data("type"); - this.updateView(guid, type, data.unread); + markAllRead: function() { + this.collection.setAllRead(); }, - markAllRead: function(evt){ - if(evt) { evt.preventDefault(); } - var self = this; - this.getAllUnread().each(function(i, el){ - self.setRead($(el).data("guid")); - }); + onChangedUnreadStatus: function(model) { + var unread = model.get("unread"); + var translationKey = unread ? "notifications.mark_read" : "notifications.mark_unread"; + var note = $(".stream-element[data-guid=" + model.guid + "]"); + + note.find(".entypo-eye") + .tooltip("destroy") + .removeAttr("data-original-title") + .attr("title", Diaspora.I18n.t(translationKey)) + .tooltip(); + + if (unread) { + note.removeClass("read").addClass("unread"); + } else { + note.removeClass("unread").addClass("read"); + } }, - updateView: function(guid, type, unread) { - var change = unread ? 1 : -1, - allNotes = $("#notifications_container .list-group > a:eq(0) .badge"), - typeNotes = $("#notifications_container .list-group > a[data-type=" + type + "] .badge"), - headerBadge = $(".notifications-link .badge"), - note = $(".notifications .stream-element[data-guid=" + guid + "]"), - markAllReadLink = $("a#mark_all_read_link"), - translationKey = unread ? "notifications.mark_read" : "notifications.mark_unread"; + updateView: function() { + var notificationsContainer = $("#notifications_container"); - if(unread){ note.removeClass("read").addClass("unread"); } - else { note.removeClass("unread").addClass("read"); } + // update notification counts in the sidebar + Object.keys(this.collection.unreadCountByType).forEach(function(notificationType) { + var count = this.collection.unreadCountByType[notificationType]; + this.updateBadge(notificationsContainer.find("a[data-type=" + notificationType + "] .badge"), count); + }.bind(this)); - $(".unread-toggle .entypo-eye", note) - .tooltip("destroy") - .removeAttr("data-original-title") - .attr("title",Diaspora.I18n.t(translationKey)) - .tooltip(); + this.updateBadge(notificationsContainer.find("a[data-type=all] .badge"), this.collection.unreadCount); - [allNotes, typeNotes, headerBadge].forEach(function(element){ - element.text(function(i, text){ - return parseInt(text) + change; - }); - }); + // update notification count in the header + this.updateBadge($(".notifications-link .badge"), this.collection.unreadCount); - [allNotes, typeNotes].forEach(function(badge) { - if(badge.text() > 0) { - badge.removeClass("hidden"); - } - else { - badge.addClass("hidden"); - } - }); + var markAllReadLink = $("a#mark-all-read-link"); - if(headerBadge.text() > 0){ - headerBadge.removeClass("hidden"); + if (this.collection.unreadCount > 0) { markAllReadLink.removeClass("disabled"); - } - else{ - headerBadge.addClass("hidden"); + } else { markAllReadLink.addClass("disabled"); } + }, + + updateBadge: function(badge, count) { + badge.text(count); + if (count > 0) { + badge.removeClass("hidden"); + } else { + badge.addClass("hidden"); + } } }); // @license-end diff --git a/app/assets/javascripts/helpers/browser_notification.js b/app/assets/javascripts/helpers/browser_notification.js new file mode 100644 index 0000000000..e8c8b7d8fd --- /dev/null +++ b/app/assets/javascripts/helpers/browser_notification.js @@ -0,0 +1,22 @@ +Diaspora.BrowserNotification = { + requestPermission: function() { + if ("Notification" in window && Notification.permission !== "granted" && Notification.permission !== "denied") { + Notification.requestPermission(); + } + }, + + spawnNotification: function(title, summary) { + if ("Notification" in window && Notification.permission === "granted") { + if (!_.isString(title)) { + throw new Error("No notification title given."); + } + + summary = summary || ""; + + new Notification(title, { + body: summary, + icon: ImagePaths.get("branding/logos/asterisk_white_mobile.png") + }); + } + } +}; diff --git a/app/assets/templates/header_tpl.jst.hbs b/app/assets/templates/header_tpl.jst.hbs index a895d99c92..ab8a27a959 100644 --- a/app/assets/templates/header_tpl.jst.hbs +++ b/app/assets/templates/header_tpl.jst.hbs @@ -53,7 +53,7 @@ <ul class="dropdown-menu" role="menu"> <div class="header"> <div class="pull-right"> - <a href="#" id="mark_all_read_link" class="btn btn-default btn-sm {{#unless current_user.notifications_count}}disabled{{/unless}}"> + <a href="#" id="mark-all-read-link" class="btn btn-default btn-sm {{#unless current_user.notifications_count}}disabled{{/unless}}"> {{t "header.mark_all_as_read"}} </a> </div> @@ -61,11 +61,10 @@ {{t "header.recent_notifications"}} </h4> </div> - <div class="notifications"> - <div class="ajax-loader"> - <div class="spinner"></div> - </div> + <div class="ajax-loader"> + <div class="spinner"></div> </div> + <div class="notifications"></div> <div class="view_all"> <a href="/notifications" id="view_all_notifications"> {{t "header.view_all"}} diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 7ddb9b8d6b..aae218b69f 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -52,7 +52,7 @@ class NotificationsController < ApplicationController format.html format.xml { render :xml => @notifications.to_xml } format.json { - render json: @notifications, each_serializer: NotificationSerializer + render json: render_as_json(@unread_notification_count, @grouped_unread_notification_counts, @notifications) } end end @@ -82,4 +82,15 @@ class NotificationsController < ApplicationController end end + private + + def render_as_json(unread_count, unread_count_by_type, notification_list) + { + unread_count: unread_count, + unread_count_by_type: unread_count_by_type, + notification_list: notification_list.map {|note| + NotificationSerializer.new(note, default_serializer_options).as_json + } + }.as_json + end end diff --git a/app/views/notifications/index.html.haml b/app/views/notifications/index.html.haml index fde543ffcc..22e647c055 100644 --- a/app/views/notifications/index.html.haml +++ b/app/views/notifications/index.html.haml @@ -7,7 +7,8 @@ = t(".notifications") .list-group %a.list-group-item{href: "/notifications" + (params[:show] == "unread" ? "?show=unread" : ""), - class: ("active" unless params[:type] && @grouped_unread_notification_counts.has_key?(params[:type]))} + class: ("active" unless params[:type] && @grouped_unread_notification_counts.has_key?(params[:type])), + data: {type: "all"}} %span.pull-right.badge{class: ("hidden" unless @unread_notification_count > 0)} = @unread_notification_count = t(".all_notifications") diff --git a/config/locales/javascript/javascript.en.yml b/config/locales/javascript/javascript.en.yml index 2f1f7362c0..b7e71a955b 100644 --- a/config/locales/javascript/javascript.en.yml +++ b/config/locales/javascript/javascript.en.yml @@ -248,6 +248,9 @@ en: notifications: mark_read: "Mark read" mark_unread: "Mark unread" + new_notifications: + one: "You have <%= count %> unread notification" + other: "You have <%= count %> unread notifications" stream: hide: "Hide" diff --git a/spec/controllers/jasmine_fixtures/notifications_spec.rb b/spec/controllers/jasmine_fixtures/notifications_spec.rb index 75f95bc801..0b1400add6 100644 --- a/spec/controllers/jasmine_fixtures/notifications_spec.rb +++ b/spec/controllers/jasmine_fixtures/notifications_spec.rb @@ -14,6 +14,8 @@ describe NotificationsController, :type => :controller do it "generates a jasmine fixture", :fixture => true do get :index save_fixture(html_for("body"), "notifications") + get :index, format: :json + save_fixture(response.body, "notifications_collection") end end end diff --git a/spec/controllers/notifications_controller_spec.rb b/spec/controllers/notifications_controller_spec.rb index b5e32bb9ef..4c4a160779 100644 --- a/spec/controllers/notifications_controller_spec.rb +++ b/spec/controllers/notifications_controller_spec.rb @@ -68,15 +68,24 @@ describe NotificationsController, :type => :controller do expect(assigns[:notifications].count).to eq(1) end - it 'succeeds for notification dropdown' do + it "succeeds for notification dropdown" do Timecop.travel(6.seconds.ago) do @notification.touch end - get :index, :format => :json + get :index, format: :json expect(response).to be_success - note_html = JSON.parse(response.body)[0]["also_commented"]["note_html"] - note_html = Nokogiri::HTML(note_html) + response_json = JSON.parse(response.body) + note_html = Nokogiri::HTML(response_json["notification_list"][0]["also_commented"]["note_html"]) timeago_content = note_html.css("time")[0]["data-time-ago"] + expect(response_json["unread_count"]).to be(1) + expect(response_json["unread_count_by_type"]).to eq( + "also_commented" => 1, + "comment_on_post" => 0, + "liked" => 0, + "mentioned" => 0, + "reshared" => 0, + "started_sharing" => 0 + ) expect(timeago_content).to include(@notification.updated_at.iso8601) expect(response.body).to match(/note_html/) end diff --git a/spec/javascripts/app/collections/notifications_collection_spec.js b/spec/javascripts/app/collections/notifications_collection_spec.js new file mode 100644 index 0000000000..d5f8cd5223 --- /dev/null +++ b/spec/javascripts/app/collections/notifications_collection_spec.js @@ -0,0 +1,249 @@ +describe("app.collections.Notifications", function() { + describe("initialize", function() { + it("calls pollNotifications", function() { + spyOn(app.collections.Notifications.prototype, "pollNotifications"); + new app.collections.Notifications(); + expect(app.collections.Notifications.prototype.pollNotifications).toHaveBeenCalled(); + }); + + it("calls Diaspora.BrowserNotification.requestPermission", function() { + spyOn(Diaspora.BrowserNotification, "requestPermission"); + new app.collections.Notifications(); + expect(Diaspora.BrowserNotification.requestPermission).toHaveBeenCalled(); + }); + + it("initializes attributes", function() { + var target = new app.collections.Notifications(); + expect(target.model).toBe(app.models.Notification); + /* eslint-disable camelcase */ + expect(target.url).toBe(Routes.notifications({per_page: 10, page: 1})); + /* eslint-enable camelcase */ + expect(target.page).toBe(2); + expect(target.perPage).toBe(5); + expect(target.unreadCount).toBe(0); + expect(target.unreadCountByType).toEqual({}); + }); + }); + + describe("pollNotifications", function() { + beforeEach(function() { + this.target = new app.collections.Notifications(); + }); + + it("calls fetch", function() { + spyOn(app.collections.Notifications.prototype, "fetch"); + this.target.pollNotifications(); + expect(app.collections.Notifications.prototype.fetch).toHaveBeenCalled(); + }); + + it("doesn't call Diaspora.BrowserNotification.spawnNotification when there are no new notifications", function() { + spyOn(Diaspora.BrowserNotification, "spawnNotification"); + this.target.pollNotifications(); + this.target.trigger("finishedLoading"); + expect(Diaspora.BrowserNotification.spawnNotification).not.toHaveBeenCalled(); + }); + + it("calls Diaspora.BrowserNotification.spawnNotification when there are new notifications", function() { + spyOn(Diaspora.BrowserNotification, "spawnNotification"); + spyOn(app.collections.Notifications.prototype, "fetch").and.callFake(function() { + this.target.unreadCount++; + }.bind(this)); + this.target.pollNotifications(); + this.target.trigger("finishedLoading"); + expect(Diaspora.BrowserNotification.spawnNotification).toHaveBeenCalled(); + }); + + it("refreshes after timeout", function() { + spyOn(app.collections.Notifications.prototype, "pollNotifications").and.callThrough(); + this.target.pollNotifications(); + expect(app.collections.Notifications.prototype.pollNotifications).toHaveBeenCalledTimes(1); + jasmine.clock().tick(2 * this.target.timeout); + expect(app.collections.Notifications.prototype.pollNotifications).toHaveBeenCalledTimes(2); + }); + }); + + describe("fetch", function() { + it("calls Backbone.Collection.prototype.fetch with correct parameters", function() { + var target = new app.collections.Notifications(); + spyOn(Backbone.Collection.prototype, "fetch"); + target.fetch({foo: "bar", remove: "bar", merge: "bar", parse: "bar"}); + expect(Backbone.Collection.prototype.fetch.calls.mostRecent().args).toEqual([{ + foo: "bar", + remove: false, + merge: true, + parse: true + }]); + }); + }); + + describe("fetchMore", function() { + beforeEach(function() { + this.target = new app.collections.Notifications(); + spyOn(app.collections.Notifications.prototype, "fetch"); + }); + + it("fetches notifications when there are more notifications to be fetched", function() { + this.target.length = 15; + this.target.fetchMore(); + /* eslint-disable camelcase */ + var route = Routes.notifications({per_page: 5, page: 3}); + /* eslint-enable camelcase */ + expect(app.collections.Notifications.prototype.fetch).toHaveBeenCalledWith({url: route, pushBack: true}); + expect(this.target.page).toBe(3); + }); + + it("doesn't fetch notifications when there are no more notifications to be fetched", function() { + this.target.length = 0; + this.target.fetchMore(); + expect(app.collections.Notifications.prototype.fetch).not.toHaveBeenCalled(); + expect(this.target.page).toBe(2); + }); + }); + + describe("set", function() { + beforeEach(function() { + this.target = new app.collections.Notifications(); + }); + + context("calls to Backbone.Collection.prototype.set", function() { + beforeEach(function() { + spyOn(Backbone.Collection.prototype, "set"); + }); + + it("calls app.collections.Notifications.prototype.set", function() { + this.target.set([]); + expect(Backbone.Collection.prototype.set).toHaveBeenCalledWith([], {at: 0}); + }); + + it("inserts the items at the beginning of the collection if option 'pushBack' is false", function() { + this.target.length = 15; + this.target.set([], {pushBack: false}); + expect(Backbone.Collection.prototype.set).toHaveBeenCalledWith([], {pushBack: false, at: 0}); + }); + + it("inserts the items at the end of the collection if option 'pushBack' is true", function() { + this.target.length = 15; + this.target.set([], {pushBack: true}); + expect(Backbone.Collection.prototype.set).toHaveBeenCalledWith([], {pushBack: true, at: 15}); + }); + }); + + context("events", function() { + beforeEach(function() { + spyOn(Backbone.Collection.prototype, "set").and.callThrough(); + spyOn(app.collections.Notifications.prototype, "trigger").and.callThrough(); + this.model1 = new app.models.Notification({"reshared": {id: 1}, "type": "reshared"}); + this.model2 = new app.models.Notification({"reshared": {id: 2}, "type": "reshared"}); + this.model3 = new app.models.Notification({"reshared": {id: 3}, "type": "reshared"}); + this.model4 = new app.models.Notification({"reshared": {id: 4}, "type": "reshared"}); + }); + + it("triggers a 'pushFront' event for each model in reverse order when option 'pushBack' is false", function() { + this.target.set([this.model1, this.model2, this.model3, this.model4], {pushBack: false}); + + var calls = app.collections.Notifications.prototype.trigger.calls; + + var index = calls.count() - 5; + expect(calls.argsFor(index)).toEqual(["pushFront", this.model4]); + expect(calls.argsFor(index + 1)).toEqual(["pushFront", this.model3]); + expect(calls.argsFor(index + 2)).toEqual(["pushFront", this.model2]); + expect(calls.argsFor(index + 3)).toEqual(["pushFront", this.model1]); + }); + + it("triggers a 'pushBack' event for each model in normal order when option 'pushBack' is true", function() { + this.target.set([this.model1, this.model2, this.model3, this.model4], {pushBack: true}); + + var calls = app.collections.Notifications.prototype.trigger.calls; + + var index = calls.count() - 5; + expect(calls.argsFor(index)).toEqual(["pushBack", this.model1]); + expect(calls.argsFor(index + 1)).toEqual(["pushBack", this.model2]); + expect(calls.argsFor(index + 2)).toEqual(["pushBack", this.model3]); + expect(calls.argsFor(index + 3)).toEqual(["pushBack", this.model4]); + }); + + it("triggers a 'finishedLoading' event at the end of the process", function() { + this.target.set([]); + expect(app.collections.Notifications.prototype.trigger).toHaveBeenCalledWith("finishedLoading"); + }); + }); + }); + + describe("parse", function() { + beforeEach(function() { + this.target = new app.collections.Notifications(); + }); + + it("sets the unreadCount and unreadCountByType attributes", function() { + expect(this.target.unreadCount).toBe(0); + expect(this.target.unreadCountByType).toEqual({}); + + /* eslint-disable camelcase */ + this.target.parse({ + unread_count: 15, + unread_count_by_type: {reshared: 6}, + notification_list: [] + }); + /* eslint-enable camelcase */ + + expect(this.target.unreadCount).toBe(15); + expect(this.target.unreadCountByType).toEqual({reshared: 6}); + }); + + it("correctly parses the result", function() { + /* eslint-disable camelcase */ + var parsed = this.target.parse({ + unread_count: 15, + unread_count_by_type: {reshared: 6}, + notification_list: [{"reshared": {id: 1}, "type": "reshared"}] + }); + /* eslint-enable camelcase */ + + expect(parsed.length).toEqual(1); + }); + + it("correctly binds the change:unread event", function() { + spyOn(app.collections.Notifications.prototype, "onChangedUnreadStatus"); + + /* eslint-disable camelcase */ + var parsed = this.target.parse({ + unread_count: 15, + unread_count_by_type: {reshared: 6}, + notification_list: [{"reshared": {id: 1}, "type": "reshared"}] + }); + /* eslint-enable camelcase */ + + parsed[0].set("unread", true); + + expect(app.collections.Notifications.prototype.onChangedUnreadStatus).toHaveBeenCalled(); + }); + }); + + describe("onChangedUnreadStatus", function() { + it("increases the unread counts when model's unread attribute is true", function() { + var target = new app.collections.Notifications(); + var model = new app.models.Notification({"reshared": {id: 1, unread: true}, "type": "reshared"}); + + target.unreadCount = 15; + target.unreadCountByType.reshared = 6; + + target.onChangedUnreadStatus(model); + + expect(target.unreadCount).toBe(16); + expect(target.unreadCountByType.reshared).toBe(7); + }); + + it("decreases the unread counts when model's unread attribute is false", function() { + var target = new app.collections.Notifications(); + var model = new app.models.Notification({"reshared": {id: 1, unread: false}, "type": "reshared"}); + + target.unreadCount = 15; + target.unreadCountByType.reshared = 6; + + target.onChangedUnreadStatus(model); + + expect(target.unreadCount).toBe(14); + expect(target.unreadCountByType.reshared).toBe(5); + }); + }); +}); diff --git a/spec/javascripts/app/models/notification_spec.js b/spec/javascripts/app/models/notification_spec.js new file mode 100644 index 0000000000..d405c34ec3 --- /dev/null +++ b/spec/javascripts/app/models/notification_spec.js @@ -0,0 +1,85 @@ +describe("app.models.Notification", function() { + beforeEach(function() { + this.model = new app.models.Notification({ + "reshared": {}, + "type": "reshared" + }); + }); + + describe("constructor", function() { + it("calls parent constructor with the correct parameters", function() { + spyOn(Backbone, "Model").and.callThrough(); + new app.models.Notification({attribute: "attribute"}, {option: "option"}); + expect(Backbone.Model).toHaveBeenCalledWith( + {attribute: "attribute"}, + {option: "option", parse: true} + ); + }); + }); + + describe("parse", function() { + it("correctly parses the object", function() { + var parsed = this.model.parse({ + "reshared": { + "id": 45, + "target_type": "Post", + "target_id": 11, + "recipient_id": 1, + "unread": true, + "created_at": "2015-10-27T19:56:30.000Z", + "updated_at": "2015-10-27T19:56:30.000Z", + "note_html": "<html/>" + }, + "type": "reshared" + }); + + expect(parsed).toEqual({ + "type": "reshared", + "id": 45, + "target_type": "Post", + "target_id": 11, + "recipient_id": 1, + "unread": true, + "created_at": "2015-10-27T19:56:30.000Z", + "updated_at": "2015-10-27T19:56:30.000Z", + "note_html": "<html/>" + }); + }); + }); + + describe("setRead", function() { + it("calls setUnreadStatus with 'false'", function() { + spyOn(app.models.Notification.prototype, "setUnreadStatus"); + new app.models.Notification({"reshared": {}, "type": "reshared"}).setRead(); + expect(app.models.Notification.prototype.setUnreadStatus).toHaveBeenCalledWith(false); + }); + }); + + describe("setUnread", function() { + it("calls setUnreadStatus with 'true'", function() { + spyOn(app.models.Notification.prototype, "setUnreadStatus"); + new app.models.Notification({"reshared": {}, "type": "reshared"}).setUnread(); + expect(app.models.Notification.prototype.setUnreadStatus).toHaveBeenCalledWith(true); + }); + }); + + describe("setUnreadStatus", function() { + beforeEach(function() { + this.target = new app.models.Notification({"reshared": {id: 16}, "type": "reshared"}); + spyOn(app.models.Notification.prototype, "set").and.callThrough(); + }); + + it("calls calls ajax with correct parameters and sets 'unread' attribute", function() { + this.target.setUnreadStatus(true); + jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: '{"guid": 16, "unread": true}'}); + var call = jasmine.Ajax.requests.mostRecent(); + + expect(call.url).toBe("/notifications/16"); + /* eslint-disable camelcase */ + expect(call.params).toEqual("set_unread=true"); + /* eslint-enable camelcase */ + expect(call.method).toEqual("PUT"); + expect(app.models.Notification.prototype.set).toHaveBeenCalledWith("unread", true); + }); + }); +}); diff --git a/spec/javascripts/app/views/header_view_spec.js b/spec/javascripts/app/views/header_view_spec.js index 0850935e8b..3bf13978f9 100644 --- a/spec/javascripts/app/views/header_view_spec.js +++ b/spec/javascripts/app/views/header_view_spec.js @@ -6,6 +6,7 @@ describe("app.views.Header", function() { spec.loadFixture("aspects_index"); gon.appConfig = {settings: {podname: "MyPod"}}; + app.notificationsCollection = new app.collections.Notifications(); this.view = new app.views.Header().render(); }); diff --git a/spec/javascripts/app/views/notification_dropdown_view_spec.js b/spec/javascripts/app/views/notification_dropdown_view_spec.js index 891023032e..69dcb4a567 100644 --- a/spec/javascripts/app/views/notification_dropdown_view_spec.js +++ b/spec/javascripts/app/views/notification_dropdown_view_spec.js @@ -1,125 +1,74 @@ describe("app.views.NotificationDropdown", function() { - beforeEach(function (){ + beforeEach(function() { spec.loadFixture("notifications"); gon.appConfig = {settings: {podname: "MyPod"}}; this.header = new app.views.Header(); $("header").prepend(this.header.el); loginAs({guid: "foo"}); this.header.render(); - this.view = new app.views.NotificationDropdown({el: "#notification-dropdown"}); + this.collection = new app.collections.Notifications(); + this.view = new app.views.NotificationDropdown({el: "#notification-dropdown", collection: this.collection}); }); - context("showDropdown", function(){ - it("Calls resetParam()", function(){ - spyOn(this.view, "resetParams"); - this.view.showDropdown(); - expect(this.view.resetParams).toHaveBeenCalled(); + describe("bindCollectionEvents", function() { + beforeEach(function() { + this.view.collection.off("pushFront"); + this.view.collection.off("pushBack"); + this.view.collection.off("finishedLoading"); + spyOn(this.view, "onPushFront"); + spyOn(this.view, "onPushBack"); + spyOn(this.view, "finishLoading"); }); - it("Calls updateScrollbar()", function(){ + + it("binds collection events", function() { + this.view.bindCollectionEvents(); + + this.collection.trigger("pushFront"); + this.collection.trigger("pushBack"); + this.collection.trigger("finishedLoading"); + + expect(this.view.onPushFront).toHaveBeenCalled(); + expect(this.view.onPushBack).toHaveBeenCalled(); + expect(this.view.finishLoading).toHaveBeenCalled(); + }); + }); + + describe("showDropdown", function() { + it("Calls updateScrollbar", function() { spyOn(this.view, "updateScrollbar"); this.view.showDropdown(); expect(this.view.updateScrollbar).toHaveBeenCalled(); }); - it("Changes CSS", function(){ + it("Changes CSS", function() { expect($("#notification-dropdown")).not.toHaveClass("dropdown-open"); this.view.showDropdown(); expect($("#notification-dropdown")).toHaveClass("dropdown-open"); }); - it("Calls getNotifications()", function(){ - spyOn(this.view, "getNotifications"); + it("Calls collection#fetch", function() { + spyOn(this.collection, "fetch"); this.view.showDropdown(); - expect(this.view.getNotifications).toHaveBeenCalled(); + expect(this.collection.fetch).toHaveBeenCalled(); }); }); - context("dropdownScroll", function(){ - it("Calls getNotifications if is at the bottom and has more notifications to load", function(){ - this.view.isBottom = function(){ return true; }; - this.view.hasMoreNotifs = true; - spyOn(this.view, "getNotifications"); + describe("dropdownScroll", function() { + it("Calls collection#fetchMore if it is at the bottom", function() { + this.view.isBottom = function() { return true; }; + spyOn(this.collection, "fetchMore"); this.view.dropdownScroll(); - expect(this.view.getNotifications).toHaveBeenCalled(); + expect(this.collection.fetchMore).toHaveBeenCalled(); }); - it("Doesn't call getNotifications if is not at the bottom", function(){ - this.view.isBottom = function(){ return false; }; - this.view.hasMoreNotifs = true; - spyOn(this.view, "getNotifications"); + it("Doesn't call collection#fetchMore if it is not at the bottom", function() { + this.view.isBottom = function() { return false; }; + spyOn(this.collection, "fetchMore"); this.view.dropdownScroll(); - expect(this.view.getNotifications).not.toHaveBeenCalled(); - }); - - it("Doesn't call getNotifications if is not at the bottom", function(){ - this.view.isBottom = function(){ return true; }; - this.view.hasMoreNotifs = false; - spyOn(this.view, "getNotifications"); - this.view.dropdownScroll(); - expect(this.view.getNotifications).not.toHaveBeenCalled(); - }); - }); - - context("getNotifications", function(){ - it("Has more notifications", function(){ - var response = ["", "", "", "", ""]; - spyOn($, "getJSON").and.callFake(function(url, callback){ callback(response); }); - this.view.getNotifications(); - expect(this.view.hasMoreNotifs).toBe(true); - }); - it("Has no more notifications", function(){ - spyOn($, "getJSON").and.callFake(function(url, callback){ callback([]); }); - this.view.getNotifications(); - expect(this.view.hasMoreNotifs).toBe(false); - }); - it("Correctly sets the next page", function(){ - spyOn($, "getJSON").and.callFake(function(url, callback){ callback([]); }); - expect(typeof this.view.nextPage).toBe("undefined"); - this.view.getNotifications(); - expect(this.view.nextPage).toBe(3); - }); - it("Increase the page count", function(){ - var response = ["", "", "", "", ""]; - spyOn($, "getJSON").and.callFake(function(url, callback){ callback(response); }); - this.view.getNotifications(); - expect(this.view.nextPage).toBe(3); - this.view.getNotifications(); - expect(this.view.nextPage).toBe(4); - }); - it("Calls renderNotifications()", function(){ - spyOn($, "getJSON").and.callFake(function(url, callback){ callback([]); }); - spyOn(this.view, "renderNotifications"); - this.view.getNotifications(); - expect(this.view.renderNotifications).toHaveBeenCalled(); - }); - it("Adds the notifications to this.notifications", function(){ - var response = ["", "", "", "", ""]; - this.view.notifications.length = 0; - spyOn($, "getJSON").and.callFake(function(url, callback){ callback(response); }); - this.view.getNotifications(); - expect(this.view.notifications).toEqual(response); - }); - }); - - context("renderNotifications", function(){ - it("Removes the previous notifications", function(){ - this.view.dropdownNotifications.append("<div class=\"media stream-element\">Notification</div>"); - expect(this.view.dropdownNotifications.find(".media.stream-element").length).toBe(1); - this.view.renderNotifications(); - expect(this.view.dropdownNotifications.find(".media.stream-element").length).toBe(0); - }); - it("Calls hideAjaxLoader()", function(){ - spyOn(this.view, "hideAjaxLoader"); - this.view.renderNotifications(); - expect(this.view.hideAjaxLoader).toHaveBeenCalled(); - }); - it("Calls updateScrollbar()", function(){ - spyOn(this.view, "updateScrollbar"); - this.view.renderNotifications(); - expect(this.view.updateScrollbar).toHaveBeenCalled(); + expect(this.collection.fetchMore).not.toHaveBeenCalled(); }); }); - context("updateScrollbar", function() { - it("Initializes perfectScrollbar", function(){ + describe("updateScrollbar", function() { + it("Initializes perfectScrollbar", function() { this.view.perfectScrollbarInitialized = false; spyOn($.fn, "perfectScrollbar"); this.view.updateScrollbar(); @@ -128,7 +77,7 @@ describe("app.views.NotificationDropdown", function() { expect(this.view.perfectScrollbarInitialized).toBeTruthy(); }); - it("Updates perfectScrollbar", function(){ + it("Updates perfectScrollbar", function() { this.view.perfectScrollbarInitialized = true; this.view.dropdownNotifications.perfectScrollbar(); spyOn($.fn, "perfectScrollbar"); @@ -139,8 +88,8 @@ describe("app.views.NotificationDropdown", function() { }); }); - context("destroyScrollbar", function() { - it("destroys perfectScrollbar", function(){ + describe("destroyScrollbar", function() { + it("destroys perfectScrollbar", function() { this.view.perfectScrollbarInitialized = true; this.view.dropdownNotifications.perfectScrollbar(); spyOn($.fn, "perfectScrollbar"); @@ -150,7 +99,7 @@ describe("app.views.NotificationDropdown", function() { expect(this.view.perfectScrollbarInitialized).toBeFalsy(); }); - it("doesn't destroy perfectScrollbar if it isn't initialized", function(){ + it("doesn't destroy perfectScrollbar if it isn't initialized", function() { this.view.perfectScrollbarInitialized = false; spyOn($.fn, "perfectScrollbar"); this.view.destroyScrollbar(); diff --git a/spec/javascripts/app/views/notifications_view_spec.js b/spec/javascripts/app/views/notifications_view_spec.js index 94f8a83946..d837703007 100644 --- a/spec/javascripts/app/views/notifications_view_spec.js +++ b/spec/javascripts/app/views/notifications_view_spec.js @@ -1,8 +1,36 @@ -describe("app.views.Notifications", function(){ +describe("app.views.Notifications", function() { + beforeEach(function() { + this.collection = new app.collections.Notifications(); + this.collection.fetch(); + jasmine.Ajax.requests.mostRecent().respondWith({ + status: 200, + responseText: spec.readFixture("notifications_collection") + }); + }); + context("on the notifications page", function() { beforeEach(function() { spec.loadFixture("notifications"); - this.view = new app.views.Notifications({el: "#notifications_container"}); + this.view = new app.views.Notifications({el: "#notifications_container", collection: this.collection}); + }); + + describe("bindCollectionEvents", function() { + beforeEach(function() { + this.view.collection.off("change"); + this.view.collection.off("update"); + spyOn(this.view, "onChangedUnreadStatus"); + spyOn(this.view, "updateView"); + }); + + it("binds collection events", function() { + this.view.bindCollectionEvents(); + + this.collection.trigger("change"); + this.collection.trigger("update"); + + expect(this.view.onChangedUnreadStatus).toHaveBeenCalled(); + expect(this.view.updateView).toHaveBeenCalled(); + }); }); describe("mark read", function() { @@ -11,11 +39,11 @@ describe("app.views.Notifications", function(){ this.guid = this.unreadN.data("guid"); }); - it("calls 'setRead'", function() { - spyOn(this.view, "setRead"); + it("calls collection's 'setRead'", function() { + spyOn(this.collection, "setRead"); this.unreadN.find(".unread-toggle").trigger("click"); - expect(this.view.setRead).toHaveBeenCalledWith(this.guid); + expect(this.collection.setRead).toHaveBeenCalledWith(this.guid); }); }); @@ -25,11 +53,11 @@ describe("app.views.Notifications", function(){ this.guid = this.readN.data("guid"); }); - it("calls 'setUnread'", function() { - spyOn(this.view, "setUnread"); + it("calls collection's 'setUnread'", function() { + spyOn(this.collection, "setUnread"); this.readN.find(".unread-toggle").trigger("click"); - expect(this.view.setUnread).toHaveBeenCalledWith(this.guid); + expect(this.collection.setUnread).toHaveBeenCalledWith(this.guid); }); }); @@ -40,42 +68,65 @@ describe("app.views.Notifications", function(){ this.type = this.readN.data("type"); }); - it("changes the 'all notifications' count", function() { + it("increases the 'all notifications' count", function() { var badge = $(".list-group > a:eq(0) .badge"); - var count = parseInt(badge.text()); + expect(parseInt(badge.text(), 10)).toBe(2); - this.view.updateView(this.guid, this.type, true); - expect(parseInt(badge.text())).toBe(count + 1); + this.collection.unreadCount++; + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(3); - this.view.updateView(this.guid, this.type, false); - expect(parseInt(badge.text())).toBe(count); + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(3); }); - it("changes the notification type count", function() { + it("decreases the 'all notifications' count", function() { + var badge = $(".list-group > a:eq(0) .badge"); + expect(parseInt(badge.text(), 10)).toBe(2); + + this.collection.unreadCount--; + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(1); + + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(1); + }); + + it("increases the notification type count", function() { var badge = $(".list-group > a[data-type=" + this.type + "] .badge"); - var count = parseInt(badge.text()); - this.view.updateView(this.guid, this.type, true); - expect(parseInt(badge.text())).toBe(count + 1); + expect(parseInt(badge.text(), 10)).toBe(1); + + this.collection.unreadCountByType[this.type]++; + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(2); - this.view.updateView(this.guid, this.type, false); - expect(parseInt(badge.text())).toBe(count); + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(2); }); - it("toggles the unread class and changes the title", function() { - this.view.updateView(this.readN.data("guid"), this.readN.data("type"), true); - expect(this.readN.hasClass("unread")).toBeTruthy(); - expect(this.readN.hasClass("read")).toBeFalsy(); - expect(this.readN.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe( - Diaspora.I18n.t("notifications.mark_read") - ); + it("decreases the notification type count", function() { + var badge = $(".list-group > a[data-type=" + this.type + "] .badge"); - this.view.updateView(this.readN.data("guid"), this.readN.data("type"), false); - expect(this.readN.hasClass("read")).toBeTruthy(); - expect(this.readN.hasClass("unread")).toBeFalsy(); - expect(this.readN.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe( - Diaspora.I18n.t("notifications.mark_unread") - ); + expect(parseInt(badge.text(), 10)).toBe(1); + + this.collection.unreadCountByType[this.type]--; + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(0); + + this.view.updateView(); + expect(parseInt(badge.text(), 10)).toBe(0); + }); + + it("hides badge count when notification count is zero", function() { + Object.keys(this.collection.unreadCountByType).forEach(function(notificationType) { + this.collection.unreadCountByType[notificationType] = 0; + }.bind(this)); + this.collection.unreadCount = 0; + + this.view.updateView(); + + expect($("a .badge")).toHaveClass("hidden"); }); context("with a header", function() { @@ -84,6 +135,7 @@ describe("app.views.Notifications", function(){ loginAs({name: "alice", avatar: {small: "http://avatar.com/photo.jpg"}, notifications_count: 2, guid: "foo"}); /* jshint camelcase: true */ gon.appConfig = {settings: {podname: "MyPod"}}; + app.notificationsCollection = this.collection; this.header = new app.views.Header(); $("header").prepend(this.header.el); this.header.render(); @@ -92,30 +144,77 @@ describe("app.views.Notifications", function(){ it("changes the header notifications count", function() { var badge1 = $(".notifications-link:eq(0) .badge"); var badge2 = $(".notifications-link:eq(1) .badge"); - var count = parseInt(badge1.text(), 10); - this.view.updateView(this.guid, this.type, true); - expect(parseInt(badge1.text(), 10)).toBe(count + 1); + expect(parseInt(badge1.text(), 10)).toBe(this.collection.unreadCount); + expect(parseInt(badge2.text(), 10)).toBe(this.collection.unreadCount); - this.view.updateView(this.guid, this.type, false); - expect(parseInt(badge1.text(), 10)).toBe(count); + this.collection.unreadCount++; + this.view.updateView(); + expect(parseInt(badge1.text(), 10)).toBe(this.collection.unreadCount); - this.view.updateView(this.guid, this.type, true); - expect(parseInt(badge2.text(), 10)).toBe(count + 1); + this.view.updateView(); + expect(parseInt(badge2.text(), 10)).toBe(this.collection.unreadCount); + }); - this.view.updateView(this.guid, this.type, false); - expect(parseInt(badge2.text(), 10)).toBe(count); + it("disables the mark-all-read-link button", function() { + expect($("a#mark-all-read-link")).not.toHaveClass("disabled"); + this.collection.unreadCount = 0; + this.view.updateView(); + expect($("a#mark-all-read-link")).toHaveClass("disabled"); }); }); }); describe("markAllRead", function() { - it("calls setRead for each unread notification", function(){ - spyOn(this.view, "setRead"); + it("calls collection#setAllRead", function() { + spyOn(this.collection, "setAllRead"); this.view.markAllRead(); - expect(this.view.setRead).toHaveBeenCalledWith(this.view.$(".stream-element.unread").eq(0).data("guid")); - this.view.markAllRead(); - expect(this.view.setRead).toHaveBeenCalledWith(this.view.$(".stream-element.unread").eq(1).data("guid")); + expect(this.collection.setAllRead).toHaveBeenCalled(); + }); + }); + + describe("onChangedUnreadStatus", function() { + beforeEach(function() { + this.modelRead = new app.models.Notification({}); + this.modelRead.set("unread", false); + this.modelRead.guid = $(".stream-element.unread").first().data("guid"); + this.modelUnread = new app.models.Notification({}); + this.modelUnread.set("unread", true); + this.modelUnread.guid = $(".stream-element.read").first().data("guid"); + }); + + it("Adds the unread class and changes the title", function() { + var unreadEl = $(".stream-element[data-guid=" + this.modelUnread.guid + "]"); + + expect(unreadEl.hasClass("read")).toBeTruthy(); + expect(unreadEl.hasClass("unread")).toBeFalsy(); + expect(unreadEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe( + Diaspora.I18n.t("notifications.mark_unread") + ); + + this.view.onChangedUnreadStatus(this.modelUnread); + expect(unreadEl.hasClass("unread")).toBeTruthy(); + expect(unreadEl.hasClass("read")).toBeFalsy(); + expect(unreadEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe( + Diaspora.I18n.t("notifications.mark_read") + ); + }); + + it("Removes the unread class and changes the title", function() { + var readEl = $(".stream-element[data-guid=" + this.modelRead.guid + "]"); + + expect(readEl.hasClass("unread")).toBeTruthy(); + expect(readEl.hasClass("read")).toBeFalsy(); + expect(readEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe( + Diaspora.I18n.t("notifications.mark_read") + ); + + this.view.onChangedUnreadStatus(this.modelRead); + expect(readEl.hasClass("read")).toBeTruthy(); + expect(readEl.hasClass("unread")).toBeFalsy(); + expect(readEl.find(".unread-toggle .entypo-eye").attr("data-original-title")).toBe( + Diaspora.I18n.t("notifications.mark_unread") + ); }); }); }); @@ -123,47 +222,32 @@ describe("app.views.Notifications", function(){ context("on the contacts page", function() { beforeEach(function() { spec.loadFixture("aspects_manage"); - this.view = new app.views.Notifications({el: "#notifications_container"}); + this.view = new app.views.Notifications({el: "#notifications_container", collection: this.collection}); /* jshint camelcase: false */ loginAs({name: "alice", avatar: {small: "http://avatar.com/photo.jpg"}, notifications_count: 2, guid: "foo"}); /* jshint camelcase: true */ gon.appConfig = {settings: {podname: "MyPod"}}; + app.notificationsCollection = this.collection; this.header = new app.views.Header(); $("header").prepend(this.header.el); this.header.render(); }); describe("updateView", function() { - it("changes the header notifications count", function() { - var badge1 = $(".notifications-link:eq(0) .badge"); - var badge2 = $(".notifications-link:eq(1) .badge"); - var count = parseInt(badge1.text(), 10); - - this.view.updateView(this.guid, this.type, true); - expect(parseInt(badge1.text(), 10)).toBe(count + 1); - - this.view.updateView(this.guid, this.type, false); - expect(parseInt(badge1.text(), 10)).toBe(count); - - this.view.updateView(this.guid, this.type, true); - expect(parseInt(badge2.text(), 10)).toBe(count + 1); - - this.view.updateView(this.guid, this.type, false); - expect(parseInt(badge2.text(), 10)).toBe(count); - }); - it("doesn't change the contacts count", function() { expect($("#aspect_nav .badge").length).toBeGreaterThan(0); $("#aspect_nav .badge").each(function(index, el) { $(el).text(index + 1337); }); - this.view.updateView(this.guid, this.type, true); + this.view.updateView(); $("#aspect_nav .badge").each(function(index, el) { expect(parseInt($(el).text(), 10)).toBe(index + 1337); }); - this.view.updateView(this.guid, this.type, false); + this.collection.unreadCount++; + + this.view.updateView(); $("#aspect_nav .badge").each(function(index, el) { expect(parseInt($(el).text(), 10)).toBe(index + 1337); }); -- GitLab