Stack notifications everywhere

We had client-side notification stacking in the tray since launch, but now we want to stack notifications in the notifications page, in API responses and in email bundles.
This commit is contained in:
Stanko K.R.
2026-02-12 10:04:32 +01:00
parent 680a718379
commit 36ee253a1a
35 changed files with 265 additions and 320 deletions
+2 -2
View File
@@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController
include CardScoped include CardScoped
def create def create
@notifications = @card.read_by(Current.user) @card.read_by(Current.user)
record_board_access record_board_access
end end
def destroy def destroy
@notifications = @card.unread_by(Current.user) @card.unread_by(Current.user)
record_board_access record_board_access
end end
@@ -1,6 +1,6 @@
class Notifications::TraysController < ApplicationController class Notifications::TraysController < ApplicationController
def show def show
@notifications = Current.user.notifications.preloaded.unread.ordered.limit(100) @notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set # Invalidate on the whole set instead of the unread set since the max updated at in the unread set
# can stay the same when reading old notifications. # can stay the same when reading old notifications.
+2 -4
View File
@@ -29,9 +29,7 @@ module NotificationsHelper
def notification_tag(notification, &) def notification_tag(notification, &)
tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: { tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: {
navigable_list_target: "item", navigable_list_target: "item",
notifications_tray_target: "notification", card_id: notification.card.id
card_id: notification.card.id,
timestamp: notification.created_at.to_i
} do } do
link_to(notification, link_to(notification,
class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ], class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ],
@@ -58,7 +56,7 @@ module NotificationsHelper
data: { action: "form#submit:stop badge#update:stop", form_target: "submit" }, data: { action: "form#submit:stop badge#update:stop", form_target: "submit" },
form: { data: { controller: "form" } } do form: { data: { controller: "form" } } do
concat(icon_tag("remove")) concat(icon_tag("remove"))
concat(tag.span("1", class: "badge-count", data: { group_count: "" })) concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1
end end
end end
end end
@@ -1,89 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "notification", "hiddenNotifications" ]
static classes = [ "grouped" ]
connect() {
this.group()
}
group() {
const notificationsByCardId = this.#groupNotificationsByCardId()
for (const cardId in notificationsByCardId) {
const notifications = notificationsByCardId[cardId]
if (notifications.length > 1) {
this.#renderGroup(notifications)
}
}
this.grouped = true
}
notificationTargetConnected(notification) {
if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) {
this.#groupNotification(notification)
}
}
#groupNotificationsByCardId() {
const notificationsByCardId = {}
this.notificationTargets.forEach(notification => {
const cardId = notification.dataset.cardId
notificationsByCardId[cardId] ||= []
notificationsByCardId[cardId].push(notification)
})
return notificationsByCardId
}
#groupNotification(notification) {
const groupedNotifications = this.#groupedNotificationsFor(notification)
if (groupedNotifications.length > 1) {
this.#renderGroup(groupedNotifications)
}
}
#groupedNotificationsFor(notification) {
const cardId = notification.dataset.cardId
return this.notificationTargets
.filter(notification => notification.dataset.cardId === cardId)
}
#renderGroup(groupedNotifications) {
groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp))
this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length)
groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification))
}
#showAsGrouped(notification, size) {
notification.classList.add(this.groupedClass)
this.#showGroupedNotification(notification)
this.#setGroupCount(notification, size)
}
#hideInGroup(notification) {
this.#hideGroupedNotification(notification)
this.#setGroupCount(notification, "")
}
// We use a hidden container instead of hiding the notifications directly so that the CSS that sort the
// tray element indexes doesn't get messed up with the child positions changing.
#showGroupedNotification(notification) {
if (notification.parentElement === this.hiddenNotificationsTarget) {
this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget)
}
}
#hideGroupedNotification(notification) {
this.hiddenNotificationsTarget.appendChild(notification)
}
#setGroupCount(notification, count) {
notification.querySelector("[data-group-count]").textContent = count
}
}
+2 -18
View File
@@ -2,15 +2,11 @@ module Card::Readable
extend ActiveSupport::Concern extend ActiveSupport::Concern
def read_by(user) def read_by(user)
notifications_for(user).tap do |notifications| user.notifications.find_by(card: self)&.read
notifications.each(&:read)
end
end end
def unread_by(user) def unread_by(user)
all_notifications_for(user).tap do |notifications| user.notifications.find_by(card: self)&.unread
notifications.each(&:unread)
end
end end
def remove_inaccessible_notifications def remove_inaccessible_notifications
@@ -25,18 +21,6 @@ module Card::Readable
Card::RemoveInaccessibleNotificationsJob.perform_later(self) Card::RemoveInaccessibleNotificationsJob.perform_later(self)
end end
def notifications_for(user)
scope = user.notifications.unread
scope.where(source: event_notification_sources)
.or(scope.where(source: mention_notification_sources))
end
def all_notifications_for(user)
scope = user.notifications
scope.where(source: event_notification_sources)
.or(scope.where(source: mention_notification_sources))
end
def event_notification_sources def event_notification_sources
events.or(comment_creation_events) events.or(comment_creation_events)
end end
+1
View File
@@ -3,6 +3,7 @@ module PushNotifiable
included do included do
after_create_commit :push_notification_later after_create_commit :push_notification_later
after_update_commit :push_notification_later, if: :source_id_previously_changed?
end end
private private
+28 -17
View File
@@ -5,32 +5,39 @@ class Notification < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :creator, class_name: "User" belongs_to :creator, class_name: "User"
belongs_to :source, polymorphic: true belongs_to :source, polymorphic: true
belongs_to :card
scope :unread, -> { where(read_at: nil) } scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) } scope :read, -> { where.not(read_at: nil) }
scope :ordered, -> { order(read_at: :desc, created_at: :desc) } scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
after_create_commit :broadcast_unread before_validation :set_card
after_destroy_commit :broadcast_read
after_create :bundle after_create :bundle
after_update :bundle, if: :source_id_previously_changed?
scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) } after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" }
after_update_commit -> { broadcast_update }
after_destroy_commit -> { broadcast_remove_to user, :notifications }
delegate :notifiable_target, to: :source delegate :notifiable_target, to: :source
delegate :card, to: :source
def self.read_all class << self
all.each { |notification| notification.read } def read_all
all.each(&:read)
end
def unread_all
all.each(&:unread)
end
end end
def read def read
update!(read_at: Time.current) update!(read_at: Time.current, unread_count: 0)
broadcast_read
end end
def unread def unread
update!(read_at: nil) update!(read_at: nil, unread_count: 1)
broadcast_unread
end end
def read? def read?
@@ -38,15 +45,19 @@ class Notification < ApplicationRecord
end end
private private
def broadcast_unread def set_card
broadcast_prepend_later_to user, :notifications, target: "notifications" self.card = source.card
end
def broadcast_read
broadcast_remove_to user, :notifications
end end
def bundle def bundle
user.bundle(self) if user.settings.bundling_emails? user.bundle(self) if user.settings.bundling_emails?
end end
def broadcast_update
if read?
broadcast_remove_to(user, :notifications)
else
broadcast_prepend_later_to(user, :notifications, target: "notifications")
end
end
end end
+2 -2
View File
@@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord
enum :status, %i[ pending processing delivered ] enum :status, %i[ pending processing delivered ]
scope :due, -> { pending.where("ends_at <= ?", Time.current) } scope :due, -> { pending.where("ends_at <= ?", Time.current) }
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) } scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) }
scope :overlapping_with, ->(other_bundle) do scope :overlapping_with, ->(other_bundle) do
where( where(
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)", "(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
@@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord
end end
def notifications def notifications
user.notifications.where(created_at: window).unread user.notifications.where(updated_at: window).unread
end end
def deliver def deliver
+7 -1
View File
@@ -16,7 +16,13 @@ class Notifier
if should_notify? if should_notify?
# Processing recipients in order avoids deadlocks if notifications overlap. # Processing recipients in order avoids deadlocks if notifications overlap.
recipients.sort_by(&:id).map do |recipient| recipients.sort_by(&:id).map do |recipient|
Notification.create! user: recipient, source: source, creator: creator notification = Notification.find_or_initialize_by(user: recipient, card: source.card)
notification.source = source
notification.creator = creator
notification.read_at = nil
notification.unread_count = (notification.unread_count || 0) + 1
notification.save!
notification
end end
end end
end end
+3 -3
View File
@@ -25,12 +25,12 @@ module User::Notifiable
def expand_pending_bundle_for(notification) def expand_pending_bundle_for(notification)
pending = notification_bundles.pending.last pending = notification_bundles.pending.last
if pending.present? && notification.created_at < pending.starts_at if pending.present? && notification.updated_at < pending.starts_at
pending.update!(starts_at: notification.created_at) # expand the window to include this notification pending.update!(starts_at: notification.updated_at) # expand the window to include this notification
end end
end end
def create_bundle_for(notification) def create_bundle_for(notification)
notification_bundles.create!(starts_at: notification.created_at) notification_bundles.create!(starts_at: notification.updated_at)
end end
end end
@@ -1,3 +0,0 @@
<% @notifications.each do |notification| %>
<%= turbo_stream.remove notification %>
<% end %>
@@ -2,7 +2,7 @@
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= notification_tag notification do %> <%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %> <%= render "notifications/notification/header", notification: notification do %>
<%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %> <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
<% end %> <% end %>
<%= render "notifications/notification/body", notification: notification %> <%= render "notifications/notification/body", notification: notification %>
<% end %> <% end %>
@@ -1,5 +1,5 @@
json.cache! notification do json.cache! notification do
json.(notification, :id) json.(notification, :id, :unread_count)
json.read notification.read? json.read notification.read?
json.read_at notification.read_at&.utc json.read_at notification.read_at&.utc
json.created_at notification.created_at.utc json.created_at notification.created_at.utc
+2 -3
View File
@@ -8,9 +8,8 @@
data-navigable-list-reverse-navigation-value="true" data-navigable-list-reverse-navigation-value="true"
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside"> data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: { <%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
controller: "notifications-tray frame-reloader", controller: "frame-reloader",
notifications_tray_grouped_class: "notification--grouped", action: "focus@window->frame-reloader#reload" } %>
action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %>
<div class="tray__item tray__item--hat txt-x-small gap-half"> <div class="tray__item tray__item--hat txt-x-small gap-half">
<div data-navigable-list-target="item" class="full-width"> <div data-navigable-list-target="item" class="full-width">
+1 -1
View File
@@ -1 +1 @@
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true
@@ -1,6 +0,0 @@
<%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %>
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
<% end %>
<%= render "notifications/notification/body", notification: notification %>
<% end %>
@@ -3,7 +3,7 @@
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2> <h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
<div id="notifications_list_read" contents> <div id="notifications_list_read" contents>
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %> <%= render partial: "notifications/notification", collection: page.records, cached: true %>
</div> </div>
</section> </section>
<% end %> <% end %>
@@ -11,12 +11,12 @@
<% end %> <% end %>
<div id="notifications_list" contents> <div id="notifications_list" contents>
<%= render partial: "notifications/index/notification", collection: unread, cached: true %> <%= render partial: "notifications/notification", collection: unread, cached: true %>
</div> </div>
<% if unread.any? %> <% if unread.any? %>
<% total_unread_count = Current.user.notifications.unread.count %> <% total_unread_count = Current.user.notifications.unread.count %>
<% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %> <% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline"> <div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden) Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
</div> </div>
@@ -1,4 +1,3 @@
<%= turbo_frame_tag "notifications" do %> <%= turbo_frame_tag "notifications" do %>
<%= render partial: "notifications/notification", collection: @notifications, cached: true %> <%= render partial: "notifications/notification", collection: @notifications, cached: true %>
<div hidden data-notifications-tray-target="hiddenNotifications"></div>
<% end %> <% end %>
@@ -0,0 +1,84 @@
class NotificationsDataMigration < ActiveRecord::Migration[8.2]
BATCH_SIZE = 10_000
class Notification < ActiveRecord::Base
self.table_name = "notifications"
end
def change
reversible do |dir|
dir.up do
populate_card_id
collapse_duplicates
end
end
change_column_null :notifications, :card_id, false
add_index :notifications, [ :user_id, :card_id ], unique: true
end
private
def populate_card_id
execute(<<~SQL)
UPDATE notifications
SET card_id = (
SELECT CASE events.eventable_type
WHEN 'Card' THEN events.eventable_id
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id)
END
FROM events
WHERE events.id = notifications.source_id
)
WHERE notifications.card_id IS NULL
AND notifications.source_type = 'Event'
SQL
execute(<<~SQL)
UPDATE notifications
SET card_id = (
SELECT CASE mentions.source_type
WHEN 'Card' THEN mentions.source_id
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id)
END
FROM mentions
WHERE mentions.id = notifications.source_id
)
WHERE notifications.card_id IS NULL
AND notifications.source_type = 'Mention'
SQL
end
def collapse_duplicates
loop do
duplicates = Notification.find_by_sql(<<~SQL)
SELECT user_id, card_id,
MAX(id) AS keep_id,
COUNT(*) AS total,
SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total
FROM notifications
WHERE card_id IS NOT NULL
GROUP BY user_id, card_id
HAVING COUNT(*) > 1
LIMIT #{BATCH_SIZE}
SQL
break if duplicates.empty?
duplicates.each do |row|
Notification.where(user_id: row.user_id, card_id: row.card_id)
.where.not(id: row.keep_id)
.delete_all
Notification.where(id: row.keep_id)
.update_all(unread_count: row.unread_total.to_i)
end
end
# Set unread_count for remaining non-collapsed notifications
execute(<<~SQL)
UPDATE notifications
SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END
WHERE unread_count = 0 AND card_id IS NOT NULL
SQL
end
end
Generated
+2 -1
View File
@@ -385,7 +385,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false t.uuid "account_id", null: false
t.uuid "card_id" t.uuid "card_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.uuid "creator_id" t.uuid "creator_id"
t.datetime "read_at" t.datetime "read_at"
@@ -397,6 +397,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["creator_id"], name: "index_notifications_on_creator_id"
t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["source_type", "source_id"], name: "index_notifications_on_source"
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
t.index ["user_id"], name: "index_notifications_on_user_id" t.index ["user_id"], name: "index_notifications_on_user_id"
end end
+2 -1
View File
@@ -385,7 +385,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
create_table "notifications", id: :uuid, force: :cascade do |t| create_table "notifications", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false t.uuid "account_id", null: false
t.uuid "card_id" t.uuid "card_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.uuid "creator_id" t.uuid "creator_id"
t.datetime "read_at" t.datetime "read_at"
@@ -397,6 +397,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["creator_id"], name: "index_notifications_on_creator_id"
t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["source_type", "source_id"], name: "index_notifications_on_source"
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
t.index ["user_id"], name: "index_notifications_on_user_id" t.index ["user_id"], name: "index_notifications_on_user_id"
end end
@@ -8,7 +8,7 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
test "create" do test "create" do
freeze_time freeze_time
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do
post card_reading_url(cards(:logo)), as: :turbo_stream post card_reading_url(cards(:logo)), as: :turbo_stream
end end
@@ -17,31 +17,20 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end end
test "read one notification on card visit" do test "read notification on card visit" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
post card_reading_path(cards(:logo)), as: :turbo_stream post card_reading_path(cards(:logo)), as: :turbo_stream
end end
assert_response :success assert_response :success
end end
test "read multiple notifications on card visit" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
post card_reading_path(cards(:logo)), as: :turbo_stream
end
end
assert_response :success
end
test "destroy" do test "destroy" do
freeze_time freeze_time
notifications(:logo_published_kevin).read
notifications(:logo_assignment_kevin).read notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do
delete card_reading_url(cards(:logo)), as: :turbo_stream delete card_reading_url(cards(:logo)), as: :turbo_stream
end end
@@ -50,26 +39,13 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end end
test "unread one notification on destroy" do test "unread notification on destroy" do
notifications(:logo_published_kevin).read notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
delete card_reading_path(cards(:logo)), as: :turbo_stream delete card_reading_path(cards(:logo)), as: :turbo_stream
end end
assert_response :success assert_response :success
end end
test "unread multiple notifications on destroy" do
notifications(:logo_published_kevin).read
notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
delete card_reading_path(cards(:logo)), as: :turbo_stream
end
end
assert_response :success
end
end end
@@ -6,7 +6,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
end end
test "create marks all notifications as read" do test "create marks all notifications as read" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
post bulk_reading_path post bulk_reading_path
end end
@@ -24,7 +24,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
end end
test "create as JSON" do test "create as JSON" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
post bulk_reading_path, as: :json post bulk_reading_path, as: :json
end end
@@ -3,38 +3,37 @@ require "test_helper"
class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in_as :kevin sign_in_as :kevin
@notification = notifications(:logo_assignment_kevin)
end end
test "create" do test "create" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do assert_changes -> { @notification.reload.read? }, from: false, to: true do
post notification_reading_path(notifications(:logo_published_kevin), format: :turbo_stream) post notification_reading_path(@notification, format: :turbo_stream)
assert_response :success assert_response :success
end end
end end
test "destroy" do test "destroy" do
notification = notifications(:logo_published_kevin) @notification.read
notification.read # Mark as read first
assert_changes -> { notification.reload.read? }, from: true, to: false do assert_changes -> { @notification.reload.read? }, from: true, to: false do
delete notification_reading_path(notification, format: :turbo_stream) delete notification_reading_path(@notification, format: :turbo_stream)
assert_response :success assert_response :success
end end
end end
test "create as JSON" do test "create as JSON" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do assert_changes -> { @notification.reload.read? }, from: false, to: true do
post notification_reading_path(notifications(:logo_published_kevin)), as: :json post notification_reading_path(@notification), as: :json
assert_response :no_content assert_response :no_content
end end
end end
test "destroy as JSON" do test "destroy as JSON" do
notification = notifications(:logo_published_kevin) @notification.read
notification.read
assert_changes -> { notification.reload.read? }, from: true, to: false do assert_changes -> { @notification.reload.read? }, from: true, to: false do
delete notification_reading_path(notification), as: :json delete notification_reading_path(@notification), as: :json
assert_response :no_content assert_response :no_content
end end
end end
@@ -10,18 +10,17 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
assert_kind_of Array, @response.parsed_body assert_kind_of Array, @response.parsed_body
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id } assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_assignment_kevin).id }
end end
test "index as JSON includes notification attributes" do test "index as JSON includes notification attributes" do
get notifications_path, as: :json get notifications_path, as: :json
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id } notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_assignment_kevin).id }
assert_not_nil notification["title"]
assert_not_nil notification["body"]
assert_not_nil notification["created_at"] assert_not_nil notification["created_at"]
assert_not_nil notification["card"] assert_not_nil notification["card"]
assert_not_nil notification["creator"] assert_not_nil notification["creator"]
assert_not_nil notification["unread_count"]
end end
end end
+8 -18
View File
@@ -1,15 +1,9 @@
logo_published_kevin:
id: <%= ActiveRecord::FixtureSet.identify("logo_published_kevin", :uuid) %>
user: kevin_uuid
source: logo_published_uuid (Event)
created_at: <%= 1.week.ago %>
creator: david_uuid
account: 37s_uuid
logo_assignment_kevin: logo_assignment_kevin:
id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %> id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %>
user: kevin_uuid user: kevin_uuid
source: logo_assignment_km_uuid (Event) source: logo_assignment_km_uuid (Event)
card: logo_uuid
unread_count: 2
created_at: <%= 1.week.ago %> created_at: <%= 1.week.ago %>
creator: david_uuid creator: david_uuid
account: 37s_uuid account: 37s_uuid
@@ -18,22 +12,18 @@ layout_commented_kevin:
id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %> id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %>
user: kevin_uuid user: kevin_uuid
source: layout_commented_uuid (Event) source: layout_commented_uuid (Event)
card: layout_uuid
unread_count: 1
created_at: <%= 1.week.ago %> created_at: <%= 1.week.ago %>
creator: david_uuid creator: david_uuid
account: 37s_uuid account: 37s_uuid
logo_card_david_mention_by_jz: logo_mentioned_david:
id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz_notif", :uuid) %> id: <%= ActiveRecord::FixtureSet.identify("logo_mentioned_david", :uuid) %>
user: david_uuid
source: logo_card_david_mention_by_jz_uuid (Mention)
created_at: <%= 1.week.ago %>
creator: david_uuid
account: 37s_uuid
logo_comment_david_mention_by_jz:
id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz_notif", :uuid) %>
user: david_uuid user: david_uuid
source: logo_comment_david_mention_by_jz_uuid (Mention) source: logo_comment_david_mention_by_jz_uuid (Mention)
card: logo_uuid
unread_count: 2
created_at: <%= 1.week.ago %> created_at: <%= 1.week.ago %>
creator: david_uuid creator: david_uuid
account: 37s_uuid account: 37s_uuid
@@ -3,6 +3,7 @@ require "test_helper"
class Notification::BundleMailerTest < ActionMailer::TestCase class Notification::BundleMailerTest < ActionMailer::TestCase
setup do setup do
@user = users(:david) @user = users(:david)
@user.notifications.destroy_all
@bundle = Notification::Bundle.create!( @bundle = Notification::Bundle.create!(
user: @user, user: @user,
+19 -42
View File
@@ -1,60 +1,41 @@
require "test_helper" require "test_helper"
class Card::ReadableTest < ActiveSupport::TestCase class Card::ReadableTest < ActiveSupport::TestCase
test "read clears events notifications" do test "read marks notification as read" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do cards(:logo).read_by(users(:kevin))
cards(:logo).read_by(users(:kevin))
end
end end
end end
test "read clear mentions in the description" do test "read marks mention notification as read" do
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: false, to: true do assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: false, to: true do
cards(:logo).read_by(users(:david)) cards(:logo).read_by(users(:david))
end end
end end
test "read clear mentions in comments" do test "read marks comment notification as read" do
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: false, to: true do
cards(:logo).read_by(users(:david))
end
end
test "read clears notifications from the comments" do
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
cards(:layout).read_by(users(:kevin)) cards(:layout).read_by(users(:kevin))
end end
end end
test "unread marks events notifications as unread" do test "unread marks notification as unread" do
notifications(:logo_published_kevin).read
notifications(:logo_assignment_kevin).read notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do cards(:logo).unread_by(users(:kevin))
cards(:logo).unread_by(users(:kevin))
end
end end
end end
test "unread marks mentions in the description as unread" do test "unread marks mention notification as unread" do
notifications(:logo_card_david_mention_by_jz).read notifications(:logo_mentioned_david).read
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: true, to: false do assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: true, to: false do
cards(:logo).unread_by(users(:david)) cards(:logo).unread_by(users(:david))
end end
end end
test "unread marks mentions in comments as unread" do test "unread marks comment notification as unread" do
notifications(:logo_comment_david_mention_by_jz).read
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: true, to: false do
cards(:logo).unread_by(users(:david))
end
end
test "unread marks notifications from the comments as unread" do
notifications(:layout_commented_kevin).read notifications(:layout_commented_kevin).read
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do
@@ -68,8 +49,8 @@ class Card::ReadableTest < ActiveSupport::TestCase
david = users(:david) david = users(:david)
assert card.accessible_to?(kevin) assert card.accessible_to?(kevin)
kevin_notifications = [ notifications(:logo_published_kevin), notifications(:logo_assignment_kevin) ] kevin_notification = notifications(:logo_assignment_kevin)
david_notifications = [ notifications(:logo_card_david_mention_by_jz), notifications(:logo_comment_david_mention_by_jz) ] david_notification = notifications(:logo_mentioned_david)
# Kevin loses access # Kevin loses access
card.board.accesses.find_by(user: kevin).destroy card.board.accesses.find_by(user: kevin).destroy
@@ -78,14 +59,10 @@ class Card::ReadableTest < ActiveSupport::TestCase
card.remove_inaccessible_notifications card.remove_inaccessible_notifications
# Kevin's notifications removed # Kevin's notification removed
kevin_notifications.each do |notification| assert_not Notification.exists?(kevin_notification.id)
assert_not Notification.exists?(notification.id)
end
# David's notifications preserved # David's notification preserved
david_notifications.each do |notification| assert Notification.exists?(david_notification.id)
assert Notification.exists?(notification.id)
end
end end
end end
+6 -5
View File
@@ -5,6 +5,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
setup do setup do
@user = users(:david) @user = users(:david)
@user.notifications.destroy_all
@user.settings.bundle_email_every_few_hours! @user.settings.bundle_email_every_few_hours!
end end
@@ -25,7 +26,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
end end
end end
test "notifications are bundled withing the aggregation period" do test "notifications are bundled within the aggregation period" do
@user.notification_bundles.destroy_all @user.notification_bundles.destroy_all
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@@ -34,12 +35,12 @@ class Notification::BundleTest < ActiveSupport::TestCase
travel_to 3.hours.from_now travel_to 3.hours.from_now
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
@user.notifications.create!(source: events(:logo_published), creator: @user) @user.notifications.create!(source: events(:layout_published), creator: @user)
end end
travel_to 3.days.from_now travel_to 3.days.from_now
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user) @user.notifications.create!(source: events(:text_published), creator: @user)
end end
assert_equal 2, @user.notification_bundles.count assert_equal 2, @user.notification_bundles.count
@@ -150,10 +151,10 @@ class Notification::BundleTest < ActiveSupport::TestCase
test "out-of-order notification bundling should still work" do test "out-of-order notification bundling should still work" do
first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user) first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
second_notification = @user.notifications.create!(source: events(:logo_published), creator: @user) second_notification = @user.notifications.create!(source: events(:layout_commented), creator: @user)
@user.notification_bundles.destroy_all @user.notification_bundles.destroy_all
assert first_notification.created_at < second_notification.created_at assert first_notification.updated_at <= second_notification.updated_at
@user.bundle(second_notification) @user.bundle(second_notification)
@user.bundle(first_notification) @user.bundle(first_notification)
+1 -4
View File
@@ -3,10 +3,7 @@ require "test_helper"
class NotificationPusherTest < ActiveSupport::TestCase class NotificationPusherTest < ActiveSupport::TestCase
setup do setup do
@user = users(:david) @user = users(:david)
@notification = @user.notifications.create!( @notification = notifications(:logo_mentioned_david)
source: events(:logo_published),
creator: users(:jason)
)
@pusher = NotificationPusher.new(@notification) @pusher = NotificationPusher.new(@notification)
@user.push_subscriptions.create!( @user.push_subscriptions.create!(
+49 -31
View File
@@ -1,49 +1,67 @@
require "test_helper" require "test_helper"
class NotificationTest < ActiveSupport::TestCase class NotificationTest < ActiveSupport::TestCase
test "unread marks notification as unread" do
notification = notifications(:logo_published_kevin)
notification.read # Mark as read first
assert_changes -> { notification.reload.read? }, from: true, to: false do
notification.unread
end
end
test "unread broadcasts to notifications" do
notification = notifications(:logo_published_kevin)
notification.read # Mark as read first
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
perform_enqueued_jobs do
notification.unread
end
end
end
test "read marks notification as read" do test "read marks notification as read" do
notification = notifications(:logo_published_kevin) notification = notifications(:logo_assignment_kevin)
# Ensure it starts as unread notification.update!(read_at: nil, unread_count: 2)
notification.update!(read_at: nil)
assert_changes -> { notification.reload.read? }, from: false, to: true do assert_changes -> { notification.reload.read? }, from: false, to: true do
notification.read notification.read
end end
assert_equal 0, notification.unread_count
end end
test "read broadcasts to notifications" do test "unread marks notification as unread" do
notification = notifications(:logo_published_kevin) notification = notifications(:logo_assignment_kevin)
# Ensure it starts as unread notification.read
notification.update!(read_at: nil)
assert_changes -> { notification.reload.read? }, from: true, to: false do
notification.unread
end
assert_equal 1, notification.unread_count
end
test "read_all marks all notifications and resets unread counts" do
kevin = users(:kevin)
kevin.notifications.unread.read_all
assert kevin.notifications.reload.all?(&:read?)
assert kevin.notifications.reload.all? { |n| n.unread_count == 0 }
end
test "unread_count tracks notification count per card" do
notification = notifications(:logo_assignment_kevin)
assert_equal 2, notification.unread_count
end
test "broadcasting on create prepends" do
kevin = users(:kevin)
layout = cards(:layout)
notifications(:layout_commented_kevin).destroy
perform_enqueued_jobs do
Notification.create!(user: kevin, source: events(:layout_commented), creator: users(:david))
end
assert_turbo_stream_broadcasts [ kevin, :notifications ]
end
test "broadcasting on update when read removes" do
notification = notifications(:layout_commented_kevin)
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
notification.read perform_enqueued_jobs do
notification.read
end
end end
end end
test "deleting notification broadcasts its removal" do test "broadcasting on destroy removes" do
notification = notifications(:logo_published_kevin) notification = notifications(:logo_assignment_kevin)
notification.update!(read_at: nil)
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
notification.destroy notification.destroy
+7 -7
View File
@@ -71,18 +71,18 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase
test "create notifications on publish for mentionees" do test "create notifications on publish for mentionees" do
users(:kevin).mentioned_by(users(:david), at: cards(:logo)) users(:kevin).mentioned_by(users(:david), at: cards(:logo))
assert_difference -> { users(:kevin).notifications.count }, +1 do notifications = Notifier.for(events(:logo_published)).notify
Notifier.for(events(:logo_published)).notify
end assert_includes notifications.map(&:user), users(:kevin)
end end
test "don'create notifications on publish for mentionees that are not watching" do test "create notifications on publish for mentionees that are not watching" do
users(:kevin).mentioned_by(users(:david), at: cards(:logo)) users(:kevin).mentioned_by(users(:david), at: cards(:logo))
cards(:logo).unwatch_by(users(:kevin)) cards(:logo).unwatch_by(users(:kevin))
assert_difference -> { users(:kevin).notifications.count }, +1 do notifications = Notifier.for(events(:logo_published)).notify
Notifier.for(events(:logo_published)).notify
end assert_includes notifications.map(&:user), users(:kevin)
end end
test "don't create notifications on comment for mentionees" do test "don't create notifications on comment for mentionees" do
+3 -2
View File
@@ -3,6 +3,7 @@ require "test_helper"
class User::NotifiableTest < ActiveSupport::TestCase class User::NotifiableTest < ActiveSupport::TestCase
setup do setup do
@user = users(:david) @user = users(:david)
@user.notifications.destroy_all
@user.settings.bundle_email_every_few_hours! @user.settings.bundle_email_every_few_hours!
end end
@@ -12,7 +13,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
end end
bundle = @user.notification_bundles.last bundle = @user.notification_bundles.last
assert_equal notification.created_at, bundle.starts_at assert_equal notification.updated_at, bundle.starts_at
assert bundle.pending? assert bundle.pending?
end end
@@ -20,7 +21,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
@user.notifications.create!(source: events(:logo_published), creator: @user) @user.notifications.create!(source: events(:logo_published), creator: @user)
assert_no_difference -> { @user.notification_bundles.count } do assert_no_difference -> { @user.notification_bundles.count } do
@user.notifications.create!(source: events(:logo_published), creator: @user) @user.notifications.create!(source: events(:layout_published), creator: @user)
end end
end end
end end
+4 -4
View File
@@ -67,13 +67,13 @@ class SmokeTest < ApplicationSystemTestCase
test "dismissing notifications" do test "dismissing notifications" do
sign_in_as(users(:david)) sign_in_as(users(:david))
notif = notifications(:logo_card_david_mention_by_jz) notification = notifications(:logo_mentioned_david)
assert_selector "div##{dom_id(notif)}" assert_selector "div##{dom_id(notification)}"
within_window(open_new_window) { visit card_url(notif.card) } within_window(open_new_window) { visit card_url(notification.card) }
assert_no_selector "div##{dom_id(notif)}" assert_no_selector "div##{dom_id(notification)}"
end end
test "dragging card to a new column" do test "dragging card to a new column" do