Merge pull request #425 from basecamp/plain-text-mentions
Plain text mentions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class GenerateNotificationsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(event)
|
||||
event.generate_notifications
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class Mention::CreateJob < ApplicationJob
|
||||
def perform(record, mentioner:)
|
||||
record.create_mentions(mentioner:)
|
||||
end
|
||||
end
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
class Notifier::Assigned < Notifier
|
||||
private
|
||||
def recipients
|
||||
event.assignees.excluding(card.collection.access_only_users)
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
class Notifier::Closed < Notifier
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
class Notifier::Commented < Notifier
|
||||
private
|
||||
def resource
|
||||
event.comment
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
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
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
logo_card_david_mention_by_jz:
|
||||
source: logo (card)
|
||||
mentioner: jz
|
||||
mentionee: david
|
||||
Vendored
+6
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user