Merge pull request #425 from basecamp/plain-text-mentions

Plain text mentions
This commit is contained in:
Jorge Manrubia
2025-04-23 17:45:16 +02:00
committed by GitHub
50 changed files with 1072 additions and 679 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ class Cards::ReadingsController < ApplicationController
include CardScoped
def create
@notifications = Current.user.notifications.where(card: @card)
@notifications = Current.user.notifications.where(source: @card.events)
@notifications.each(&:read)
end
end
+20 -21
View File
@@ -1,20 +1,19 @@
module NotificationsHelper
def notification_title(notification)
if notification.resource.is_a? Comment
"RE: " + notification.card.title
elsif notification_event_action(notification) == "assigned"
"Assigned to you: " + notification.card.title
else
notification.card.title
def event_notification_title(event)
case event_notification_action(event)
when "commented" then "RE: " + event.card.title
when "assigned" then "Assigned to you: " + event.card.title
else event.card.title
end
end
def notification_body(notification)
name = notification.creator.name
def event_notification_body(event)
name = event.creator.name
case notification_event_action(notification)
case event_notification_action(event)
when "closed" then "Closed by #{name}"
when "published" then "Added by #{name}"
when "commented" then comment_notification_body(event)
else name
end
end
@@ -22,7 +21,7 @@ module NotificationsHelper
def notification_tag(notification, &)
tag.div id: dom_id(notification), class: "notification tray__item border-radius txt-normal" do
concat(
link_to(notification.resource,
link_to(notification,
class: "notification__content border-radius shadow fill-white flex align-start txt-align-start gap flex-item-grow max-width border txt-ink",
data: { action: "click->dialog#close", turbo_frame: "_top" },
&)
@@ -33,11 +32,11 @@ module NotificationsHelper
def notification_mark_read_button(notification)
button_to read_notification_path(notification),
class: "notification__unread_indicator btn borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
concat(tag.span("Mark as read", class: "for-screen-reader"))
class: "notification__unread_indicator btn borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
concat(tag.span("Mark as read", class: "for-screen-reader"))
end
end
@@ -48,15 +47,15 @@ module NotificationsHelper
end
private
def notification_event_action(notification)
if notification_is_for_initial_assignement?(notification)
def event_notification_action(event)
if event.initial_assignment?
"assigned"
else
notification.event.action
event.action
end
end
def notification_is_for_initial_assignement?(notification)
notification.event.action == "published" && notification.card.assigned_to?(notification.user)
def comment_notification_body(event)
"#{strip_tags(event.comment.body_html).blank? ? "#{name} replied" : "#{event.creator.name}:" } #{strip_tags(event.comment.body_html).truncate(200)}"
end
end
-2
View File
@@ -1,6 +1,4 @@
class Card::AutoCloseAllDueJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_close_all_due
@@ -1,6 +1,4 @@
class Card::AutoReconsiderAllStagnatedJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_reconsider_all_stagnated
-7
View File
@@ -1,7 +0,0 @@
class GenerateNotificationsJob < ApplicationJob
queue_as :default
def perform(event)
event.generate_notifications
end
end
+5
View File
@@ -0,0 +1,5 @@
class Mention::CreateJob < ApplicationJob
def perform(record, mentioner:)
record.create_mentions(mentioner:)
end
end
+5
View File
@@ -0,0 +1,5 @@
class NotifyRecipientsJob < ApplicationJob
def perform(notifiable)
notifiable.notify_recipients
end
end
@@ -1,6 +1,4 @@
class RemoveAbandonedCreationsJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.remove_abandoned_creations
+6 -4
View File
@@ -1,12 +1,10 @@
class Card < ApplicationRecord
include Assignable, Colored, Engageable, Eventable, Golden, Messages, Notifiable,
Pinnable, Closeable, Searchable, Staged, Statuses, Taggable, Watchable
include Assignable, Colored, Engageable, Eventable, Golden, Mentions,
Messages, Pinnable, Closeable, Searchable, Staged, Statuses, Taggable, Watchable
belongs_to :collection, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :notifications, dependent: :destroy
has_one_attached :image, dependent: :purge_later
has_markdown :description
@@ -32,4 +30,8 @@ class Card < ApplicationRecord
def cache_key
[ super, collection.name ].compact.join("/")
end
def was_mentioned(mention)
watch_by(mention.mentionee)
end
end
+1 -2
View File
@@ -12,8 +12,7 @@ module Card::Eventable
def track_event(action, creator: Current.user, **particulars)
if published?
event = find_or_capture_event_summary.events.create! action: action, creator: creator, card: self, particulars: particulars
event.generate_notifications_later
find_or_capture_event_summary.events.create! action: action, creator: creator, card: self, particulars: particulars
end
end
+3 -1
View File
@@ -1,5 +1,5 @@
class Comment < ApplicationRecord
include Messageable, Searchable
include Mentions, Messageable, Searchable
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :reactions, dependent: :delete_all
@@ -13,6 +13,8 @@ class Comment < ApplicationRecord
after_create_commit :watch_card_by_creator, :track_commented_card
after_destroy_commit :cleanup_events
delegate :watch_by, to: :card
def to_partial_path
"cards/#{super}"
end
+46
View File
@@ -0,0 +1,46 @@
module Mentions
extend ActiveSupport::Concern
included do
has_many :mentions, as: :source, dependent: :destroy
has_many :mentionees, through: :mentions
after_save_commit :create_mentions_later, if: :mentionable_content_changed?
end
def create_mentions(mentioner: Current.user)
scan_mentionees.each do |mentionee|
mentionee.mentioned_by mentioner, at: self
end
end
def mentionable_content
markdown_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ")
end
private
def scan_mentionees
scan_mentioned_handles.filter_map do |mention|
mentionable_users.find { |user| user.mentionable_handles.include?(mention) }
end
end
def scan_mentioned_handles
mentionable_content.scan(/(?<!\w)@(\w+)/).flatten.uniq(&:downcase)
end
def mentionable_users
collection.users
end
def markdown_associations
self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::Markdown }
end
def mentionable_content_changed?
markdown_associations.any? { send(it.name).content_previously_changed? }
end
def create_mentions_later
Mention::CreateJob.perform_later(self, mentioner: Current.user)
end
end
+2
View File
@@ -6,5 +6,7 @@ module Messageable
included do
has_one :message, as: :messageable, touch: true, dependent: :destroy
has_one :card, through: :message
delegate :collection, to: :card
end
end
+16 -1
View File
@@ -2,6 +2,21 @@ module Notifiable
extend ActiveSupport::Concern
included do
has_many :notifications, as: :resource, dependent: :destroy
has_many :notifications, as: :source, dependent: :destroy
after_create_commit :notify_recipients_later
end
def notify_recipients
Notifier.for(self)&.notify
end
def notifiable_target
self
end
private
def notify_recipients_later
NotifyRecipientsJob.perform_later self
end
end
+11 -7
View File
@@ -1,5 +1,5 @@
class Event < ApplicationRecord
include Particulars
include Notifiable, Particulars
belongs_to :creator, class_name: "User"
belongs_to :summary, touch: true, class_name: "EventSummary"
@@ -12,15 +12,19 @@ class Event < ApplicationRecord
after_create -> { card.touch(:last_active_at) }
def commented?
action == "commented"
def action
super.inquiry
end
def generate_notifications
Notifier.for(self)&.generate
def notifiable_target
if action.commented?
comment
else
card
end
end
def generate_notifications_later
GenerateNotificationsJob.perform_later self
def initial_assignment?
action == "published" && card.assigned_to?(creator)
end
end
+22
View File
@@ -0,0 +1,22 @@
class Mention < ApplicationRecord
include Notifiable
belongs_to :source, polymorphic: true
belongs_to :mentioner, class_name: "User"
belongs_to :mentionee, class_name: "User", inverse_of: :mentions
after_create_commit :add_mentionee_as_watcher
def self_mention?
mentioner == mentionee
end
def notifiable_target
source
end
private
def add_mentionee_as_watcher
source.watch_by(mentionee)
end
end
+4 -4
View File
@@ -1,16 +1,16 @@
class Notification < ApplicationRecord
belongs_to :user
belongs_to :event
belongs_to :card
belongs_to :resource, polymorphic: true
belongs_to :creator, class_name: "User"
belongs_to :source, polymorphic: true
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
delegate :creator, to: :event
after_create_commit :broadcast_unread
delegate :notifiable_target, to: :source
def self.read_all
update!(read_at: Time.current)
end
+13 -22
View File
@@ -1,40 +1,31 @@
class Notifier
attr_reader :event
delegate :creator, to: :event
attr_reader :source
class << self
def for(event)
"Notifier::#{event.action.classify}".safe_constantize&.new(event)
def for(source)
case source
when Event
EventNotifier.new(source)
when Mention
MentionNotifier.new(source)
end
end
end
def generate
def notify
if should_notify?
recipients.map do |recipient|
Notification.create! user: recipient, event: event, card: card, resource: resource
Notification.create! user: recipient, source: source, creator: creator
end
end
end
private
def initialize(event)
@event = event
def initialize(source)
@source = source
end
def should_notify?
!event.creator.system?
end
def recipients
card.watchers_and_subscribers.without(creator)
end
def resource
card
end
def card
event.summary.message.card
!creator.system?
end
end
-6
View File
@@ -1,6 +0,0 @@
class Notifier::Assigned < Notifier
private
def recipients
event.assignees.excluding(card.collection.access_only_users)
end
end
-2
View File
@@ -1,2 +0,0 @@
class Notifier::Closed < Notifier
end
-6
View File
@@ -1,6 +0,0 @@
class Notifier::Commented < Notifier
private
def resource
event.comment
end
end
+18
View File
@@ -0,0 +1,18 @@
class Notifier::EventNotifier < Notifier
delegate :card, :creator, to: :source
delegate :watchers_and_subscribers, to: :card
private
def recipients
case source.action
when "assigned"
source.assignees.excluding(card.collection.access_only_users)
when "published"
watchers_and_subscribers(include_only_watching: true).without(creator, *card.mentionees)
when "commented"
watchers_and_subscribers.without(creator, *source.comment.mentionees)
else
watchers_and_subscribers.without(creator)
end
end
end
+16
View File
@@ -0,0 +1,16 @@
class Notifier::MentionNotifier < Notifier
alias mention source
private
def recipients
if mention.self_mention?
[]
else
[ mention.mentionee ]
end
end
def creator
mention.mentioner
end
end
-6
View File
@@ -1,6 +0,0 @@
class Notifier::Published < Notifier
private
def recipients
card.watchers_and_subscribers(include_only_watching: true).without(creator)
end
end
+1 -7
View File
@@ -1,5 +1,5 @@
class User < ApplicationRecord
include Accessor, Assignee, Role, Transferable
include Accessor, Assignee, Mentionable, Named, Role, Transferable
has_one_attached :avatar
@@ -17,12 +17,6 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase }
scope :alphabetically, -> { order("lower(name)") }
def initials
name.to_s.scan(/\b\p{L}/).join.upcase
end
def deactivate
sessions.delete_all
accesses.destroy_all
+20
View File
@@ -0,0 +1,20 @@
module User::Mentionable
extend ActiveSupport::Concern
included do
has_many :mentions, dependent: :destroy, inverse_of: :mentionee
end
def mentioned_by(mentioner, at:)
mentions.find_or_create_by! source: at, mentioner: mentioner
end
def mentionable_handles
[ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase)
end
private
def first_name_with_last_name_initial
"#{first_name}#{last_name&.first}"
end
end
+19
View File
@@ -0,0 +1,19 @@
module User::Named
extend ActiveSupport::Concern
included do
scope :alphabetically, -> { order("lower(name)") }
end
def first_name
name.split(/\s/).first
end
def last_name
name.split(/\s/, 2).last
end
def initials
name.scan(/\b\p{L}/).join.upcase
end
end
+1 -1
View File
@@ -11,7 +11,7 @@
<%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %>
</strong>
<%= link_to card_path(comment.card, anchor: "comment_#{comment.id}"), class: "txt-undecorated txt-uppercase" do %>
<%= link_to comment, class: "txt-undecorated txt-uppercase" do %>
<%= local_datetime_tag comment.created_at, style: :shortdate, class: "txt-ink translucent" %>
<% end %>
+1 -1
View File
@@ -2,7 +2,7 @@
data-related-element-highlight-class="event--related">
<% if events.any? %>
<h2 class="txt-medium events__day-header center">
<span class="events__day-header-content"><%= event_day_title(activity_day) %><span>
<span class="events__day-header-content"><%= event_day_title(activity_day) %></span>
</h2>
<div class="center events">
@@ -5,15 +5,7 @@
</div>
<div class="flex flex-column min-width flex-item-grow">
<strong class="overflow-ellipsis notification__title txt-small txt-tight-lines"><%= notification_title(notification) %></strong>
<div class="overflow-ellipsis txt-small txt-tight-lines">
<% if notification.event.action == "commented" %>
<%= "#{strip_tags(notification.event.comment.body_html).blank? ? "#{notification.event.creator.name} replied" : "#{notification.event.creator.name}:" } #{strip_tags(notification.event.comment.body_html).truncate(200)}" %>
<% else %>
<%= notification_body(notification) %>
<% end %>
</div>
<div class="tray__item-meta overflow-ellipsis translucent"><%= notification.card.collection.name %> · <%= local_datetime_tag(notification.created_at, style: :ago) %></div>
<%= render "notifications/notification/#{notification.source_type.underscore}", notification: notification %>
</div>
<% end %>
<% end %>
@@ -0,0 +1,7 @@
<% event = notification.source %>
<strong class="overflow-ellipsis notification__title txt-small txt-tight-lines"><%= event_notification_title(event) %></strong>
<div class="overflow-ellipsis txt-small txt-tight-lines">
<%= event_notification_body(event) %>
</div>
<div class="tray__item-meta overflow-ellipsis translucent"><%= notification.source.card.collection.name %> · <%= local_datetime_tag(notification.created_at, style: :ago) %></div>
@@ -0,0 +1,7 @@
<% mention = notification.source %>
<strong class="overflow-ellipsis notification__title txt-small txt-tight-lines"><%= mention.mentioner.first_name %> mentioned you</strong>
<div class="overflow-ellipsis txt-small txt-tight-lines">
<%= mention.source.mentionable_content.truncate(200) %>
</div>
<div class="tray__item-meta overflow-ellipsis translucent"> <%= local_datetime_tag(notification.created_at, style: :ago) %></div>
+7
View File
@@ -103,6 +103,13 @@ Rails.application.routes.draw do
route_for :collection_card, comment.card.collection, comment.card, options
end
resolve "Mention" do |mention, options|
polymorphic_path(mention.source, options)
end
resolve "Notification" do |notification, options|
polymorphic_path(notification.notifiable_target, options)
end
get "up", to: "rails/health#show", as: :rails_health_check
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
@@ -0,0 +1,11 @@
class CreateMentions < ActiveRecord::Migration[8.1]
def change
create_table :mentions do |t|
t.references :source, polymorphic: true, null: false, index: true
t.references :mentionee, foreign_key: { to_table: :users }, null: false
t.references :mentioner, foreign_key: { to_table: :users }, null: false
t.timestamps
end
end
end
@@ -0,0 +1,11 @@
class AddSourceToNotifications < ActiveRecord::Migration[8.1]
def change
add_reference :notifications, :source, polymorphic: true, index: true
execute <<~SQL
update notifications set source_type = 'Event'
SQL
change_column_null :notifications, :source_type, false
end
end
@@ -0,0 +1,6 @@
class RemoveUnusedColumnsFromNotifications < ActiveRecord::Migration[8.1]
def change
remove_column :notifications, :event_id
remove_column :notifications, :card_id
end
end
@@ -0,0 +1,18 @@
class AddCreatorToNotifications < ActiveRecord::Migration[8.1]
def change
add_reference :notifications, :creator, null: true, foreign_key: { to_table: :users }
execute <<~SQL
UPDATE notifications
SET creator_id = (
SELECT events.creator_id
FROM events
WHERE events.id = notifications.source_id
AND notifications.source_type = 'Event'
)
WHERE source_type = 'Event';
SQL
change_column_null :notifications, :creator_id, true
end
end
@@ -0,0 +1,5 @@
class RemoveNotificationResources < ActiveRecord::Migration[8.1]
def change
remove_reference :notifications, :resource, polymorphic: true, index: true
end
end
Generated
+21 -10
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_04_22_091602) do
ActiveRecord::Schema[8.1].define(version: 2025_04_23_114737) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -211,6 +211,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_22_091602) do
t.index ["tag_id"], name: "index_filters_tags_on_tag_id"
end
create_table "mentions", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "mentionee_id", null: false
t.integer "mentioner_id", null: false
t.integer "source_id", null: false
t.string "source_type", null: false
t.datetime "updated_at", null: false
t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id"
t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id"
t.index ["source_type", "source_id"], name: "index_mentions_on_source"
end
create_table "messages", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
@@ -222,17 +234,15 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_22_091602) do
end
create_table "notifications", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
t.integer "event_id", null: false
t.integer "creator_id"
t.datetime "read_at"
t.integer "resource_id", null: false
t.string "resource_type", null: false
t.integer "source_id"
t.string "source_type", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["card_id"], name: "index_notifications_on_card_id"
t.index ["event_id"], name: "index_notifications_on_event_id"
t.index ["resource_type", "resource_id"], name: "index_notifications_on_resource"
t.index ["creator_id"], name: "index_notifications_on_creator_id"
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
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"
end
@@ -327,10 +337,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_22_091602) do
add_foreign_key "collections", "workflows"
add_foreign_key "events", "cards"
add_foreign_key "events", "event_summaries", column: "summary_id"
add_foreign_key "mentions", "users", column: "mentionee_id"
add_foreign_key "mentions", "users", column: "mentioner_id"
add_foreign_key "messages", "cards"
add_foreign_key "notifications", "cards"
add_foreign_key "notifications", "events"
add_foreign_key "notifications", "users"
add_foreign_key "notifications", "users", column: "creator_id"
add_foreign_key "pins", "cards"
add_foreign_key "pins", "users"
add_foreign_key "sessions", "users"
+554 -484
View File
File diff suppressed because it is too large Load Diff
@@ -15,9 +15,9 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
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
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
+4
View File
@@ -0,0 +1,4 @@
logo_card_david_mention_by_jz:
source: logo (card)
mentioner: jz
mentionee: david
+6 -9
View File
@@ -1,20 +1,17 @@
logo_published_kevin:
user: kevin
event: logo_published
card: logo
resource: logo (Card)
source: logo_published (Event)
created_at: <%= 1.week.ago %>
creator: david
logo_assignment_kevin:
user: kevin
event: logo_assignment_km
card: logo
resource: logo (Card)
source: logo_assignment_km (Event)
created_at: <%= 1.week.ago %>
creator: david
layout_commented_kevin:
user: kevin
event: layout_commented
card: layout
resource: layout_overflowing_david (Comment)
source: layout_commented (Event)
created_at: <%= 1.week.ago %>
creator: david
+15
View File
@@ -0,0 +1,15 @@
require "test_helper"
class MentionsTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "create mentions when creating messages" do
assert_difference -> { Mention.count }, +1 do
perform_enqueued_jobs only: Mention::CreateJob do
collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?"
end
end
end
end
@@ -0,0 +1,81 @@
require "test_helper"
class Notifier::EventNotifierTest < ActiveSupport::TestCase
test "for returns the matching notifier class for the event" do
assert_kind_of Notifier::EventNotifier, Notifier.for(events(:logo_published))
end
test "generate does not create notifications if the event was system-generated" do
cards(:logo).drafted!
events(:logo_published).update!(creator: User.system)
assert_no_difference -> { Notification.count } do
Notifier.for(events(:logo_published)).notify
end
end
test "creates a notification for each watcher, other than the event creator (events)" do
notifications = Notifier.for(events(:layout_commented)).notify
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "creates a notification for each watcher (mentions)" do
notifications = Notifier.for(events(:layout_commented)).notify
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "does not create a notification for access-only users" do
collections(:writebook).access_for(users(:kevin)).access_only!
notifications = Notifier.for(events(:layout_commented)).notify
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "the published event creates notifications for subscribers as well as watchers" do
notifications = Notifier.for(events(:logo_published)).notify
assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort
end
test "links to the card" do
Notifier.for(events(:logo_published)).notify
assert_equal cards(:logo), Notification.last.source.card
end
test "assignment events only create a notification for the assignee" do
collections(:writebook).access_for(users(:jz)).watching!
collections(:writebook).access_for(users(:kevin)).everything!
notifications = Notifier.for(events(:logo_assignment_jz)).notify
assert_equal [ users(:jz) ], notifications.map(&:user)
end
test "assignment events do not notify users who are access-only for the collection" do
collections(:writebook).access_for(users(:jz)).access_only!
notifications = Notifier.for(events(:logo_assignment_jz)).notify
assert_empty notifications
end
test "don't create notifications on publish for mentionees" do
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
assert_no_difference -> { users(:kevin).notifications.count } do
Notifier.for(events(:logo_published)).notify
end
end
test "don't create notifications on comment for mentionees" do
users(:david).mentioned_by(users(:kevin), at: cards(:layout))
assert_no_difference -> { users(:david).notifications.count } do
Notifier.for(events(:layout_commented)).notify
end
end
end
@@ -0,0 +1,27 @@
require "test_helper"
class Notifier::EventNotifierTest < ActiveSupport::TestCase
test "for returns the matching notifier class for the mention" do
assert_kind_of Notifier::MentionNotifier, Notifier.for(mentions(:logo_card_david_mention_by_jz))
end
test "notify the mentionee" do
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
assert_no_difference -> { users(:kevin).notifications.count } do
Notifier.for(mentions(:logo_card_david_mention_by_jz)).notify
end
end
test "create notifications for mentionee" do
assert_no_difference -> { users(:david).notifications.count } do
Notifier.for(events(:layout_commented)).notify
end
end
test "don't create notifications for self-mentions" do
assert_no_difference -> { users(:jz).notifications.count } do
Notifier.for(events(:layout_commented)).notify
end
end
end
-59
View File
@@ -1,59 +0,0 @@
require "test_helper"
class NotifierTest < ActiveSupport::TestCase
test "for returns the matching notifier class for the event" do
assert_kind_of Notifier::Published, Notifier.for(events(:logo_published))
end
test "generate does not create notifications if the event was system-generated" do
cards(:logo).drafted!
events(:logo_published).update!(creator: User.system)
assert_no_difference -> { Notification.count } do
Notifier.for(events(:logo_published)).generate
end
end
test "creates a notification for each watcher, other than the event creator" do
notifications = Notifier.for(events(:layout_commented)).generate
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "does not create a notification for access-only users" do
collections(:writebook).access_for(users(:kevin)).access_only!
notifications = Notifier.for(events(:layout_commented)).generate
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "the published event creates notifications for subscribers as well as watchers" do
notifications = Notifier.for(events(:logo_published)).generate
assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort
end
test "links to the card" do
Notifier.for(events(:logo_published)).generate
assert_equal cards(:logo), Notification.last.resource
end
test "assignment events only create a notification for the assignee" do
collections(:writebook).access_for(users(:jz)).watching!
collections(:writebook).access_for(users(:kevin)).everything!
notifications = Notifier.for(events(:logo_assignment_jz)).generate
assert_equal [ users(:jz) ], notifications.map(&:user)
end
test "assignment events do not notify users who are access-only for the collection" do
collections(:writebook).access_for(users(:jz)).access_only!
notifications = Notifier.for(events(:logo_assignment_jz)).generate
assert_empty notifications
end
end
+18
View File
@@ -0,0 +1,18 @@
require "test_helper"
class User::MentionableTest < ActiveSupport::TestCase
test "mentionable handles" do
assert_equal [ "dhh", "david", "davidh" ], User.new(name: "David Heinemeier-Hansson").mentionable_handles
end
test "mentioned by" do
assert_difference -> { users(:david).mentions.count }, +1 do
users(:david).mentioned_by users(:jz), at: cards(:logo)
end
# No dups
assert_no_difference -> { users(:david).mentions.count }, +1 do
users(:david).mentioned_by users(:jz), at: cards(:logo)
end
end
end
+38
View File
@@ -0,0 +1,38 @@
require "test_helper"
class User::NamedTest < ActiveSupport::TestCase
test "initials" do
assert_initials "M", name: "Michael"
assert_initials "SD", name: "Salvador Dali"
assert_initials "LMM", name: "Lin-Manuel Miranda"
assert_initials "OCD", name: "O'Conor Díez"
assert_initials "ACG", name: "Anne Christine García"
assert_initials "ÁL", name: "Ángela López"
end
test "first name" do
assert_first_name "Michael", "Michael"
assert_first_name "Salvador", "Salvador Dali"
assert_first_name "Lin-Manuel", "Lin-Manuel Miranda"
assert_first_name "Anne", "Anne Christine García"
end
test "last name" do
assert_last_name "Dali", "Salvador Dali"
assert_last_name "Miranda", "Lin_Manuel Miranda"
assert_last_name "Christine García", "Anne Christine García"
end
private
def assert_initials(expected, **attributes)
assert_equal expected, User.new(attributes).initials
end
def assert_first_name(expected, name)
assert_equal expected, User.new(name: name).first_name
end
def assert_last_name(expected, name)
assert_equal expected, User.new(name: name).last_name
end
end
+1
View File
@@ -10,6 +10,7 @@ module ActiveSupport
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
include ActiveJob::TestHelper
include CardTestHelper, ChangeTestHelper, SessionTestHelper
end
end