diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css new file mode 100644 index 000000000..4b58106a9 --- /dev/null +++ b/app/assets/stylesheets/notifications.css @@ -0,0 +1,84 @@ +.notification-tray { + height: 4rem; + inset: auto auto var(--block-space); + position: fixed; +} + +.notification { + inset: auto auto 0; + position: absolute; + transform: translate(var(--offsetX), var(--offsetY)); + transform-origin: center center; + transition: transform 0.2s ease-in-out; + z-index: var(--z-index); + + .notification__content { + color: var(--color-ink); + inline-size: var(--width); + } + + &:nth-child(1) { + --offsetX: 0; + --offsetY: 0; + --width: 40ch; + --z-index: 0; + } + + &:nth-child(2) { + --offsetX: 0.5ch; + --offsetY: calc(var(--block-space-half) * -1); + --width: 39ch; + --z-index: -1; + } + + &:nth-child(3) { + --offsetX: 1ch; + --offsetY: calc(var(--block-space) * -1); + --width: 38ch; + --z-index: -2; + } + + &:nth-child(4) { + --offsetX: 1.5ch; + --offsetY: calc(var(--block-space) * -1.5); + --width: 37ch; + --z-index: -3; + } + + &:nth-child(5) { + --offsetX: 2ch; + --offsetY: calc(var(--block-space) * -2); + --width: 36ch; + --z-index: -4; + } + + &:nth-child(1n + 6) { + display: none; + } + + .notification-tray:hover & { + &:nth-child(2) { + --offsetX: 0; + --offsetY: calc((100% + var(--block-space-half)) * -1); + --width: 40ch; + } + + &:nth-child(3) { + --offsetX: 0; + --offsetY: calc((100% + var(--block-space-half)) * -2); + --width: 40ch; + } + + &:nth-child(4) { + --offsetX: 0; + --offsetY: calc((100% + var(--block-space-half)) * -3); + --width: 40ch; + } + + &:nth-child(5) { + --offsetX: 0; + --offsetY: calc((100% + var(--block-space-half)) * -4); + --width: 40ch; + } + } +} diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb new file mode 100644 index 000000000..22e40167e --- /dev/null +++ b/app/controllers/notifications_controller.rb @@ -0,0 +1,5 @@ +class NotificationsController < ApplicationController + def index + @notifications = Current.user.notifications.unread.ordered.limit(20) + end +end diff --git a/app/controllers/readings_controller.rb b/app/controllers/readings_controller.rb new file mode 100644 index 000000000..9c5798ed3 --- /dev/null +++ b/app/controllers/readings_controller.rb @@ -0,0 +1,13 @@ +class ReadingsController < ApplicationController + include BubbleScoped, BucketScoped + + def create + mark_bubble_notifications_read + @notifications = Current.user.notifications.unread.ordered.limit(20) + end + + private + def mark_bubble_notifications_read + Current.user.notifications.where(bubble: @bubble).update(read: true) + end +end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb new file mode 100644 index 000000000..7c35a5080 --- /dev/null +++ b/app/helpers/notifications_helper.rb @@ -0,0 +1,27 @@ +module NotificationsHelper + def notification_title(notification) + title = notification.bubble.title + + if notification.resource.is_a? Comment + "RE: " + title + else + title + end + end + + def notification_body(notification) + name = notification.creator.name + + case notification.event.action + when "assigned" then "#{name} assigned to you" + when "created" then "Added by #{name}" + when "popped" then "Popped by by #{name}" + else name + end + end + + def notification_tag(notification, &) + link_to notification.resource, id: dom_id(notification), class: "notification border-radius", + data: { turbo_frame: "_top" }, & + end +end diff --git a/app/javascript/controllers/beacon_controller.js b/app/javascript/controllers/beacon_controller.js new file mode 100644 index 000000000..07182bb36 --- /dev/null +++ b/app/javascript/controllers/beacon_controller.js @@ -0,0 +1,10 @@ +import { post } from "@rails/request.js" +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { url: String } + + connect() { + post(this.urlValue, { responseKind: "turbo-stream" }) + } +} diff --git a/app/jobs/generate_notifications_job.rb b/app/jobs/generate_notifications_job.rb new file mode 100644 index 000000000..e386b7b37 --- /dev/null +++ b/app/jobs/generate_notifications_job.rb @@ -0,0 +1,7 @@ +class GenerateNotificationsJob < ApplicationJob + queue_as :default + + def perform(event) + event.generate_notifications + end +end diff --git a/app/models/bubble.rb b/app/models/bubble.rb index 5da059a49..5b6a0ec93 100644 --- a/app/models/bubble.rb +++ b/app/models/bubble.rb @@ -1,9 +1,11 @@ class Bubble < ApplicationRecord - include Assignable, Boostable, Colored, Commentable, Eventable, Messages, Poppable, Searchable, Staged, Taggable + include Assignable, Boostable, Colored, Commentable, Eventable, Messages, Notifiable, Poppable, Searchable, Staged, Taggable belongs_to :bucket, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } + has_many :notifications, dependent: :destroy + has_one_attached :image, dependent: :purge_later before_save :set_default_title diff --git a/app/models/bubble/commentable.rb b/app/models/bubble/commentable.rb index f80f34777..152eeb7a0 100644 --- a/app/models/bubble/commentable.rb +++ b/app/models/bubble/commentable.rb @@ -5,8 +5,9 @@ module Bubble::Commentable scope :ordered_by_comments, -> { order comments_count: :desc } end - def comment_created + def comment_created(comment) increment! :comments_count + track_event :commented, comment_id: comment.id rescore end diff --git a/app/models/bubble/eventable.rb b/app/models/bubble/eventable.rb index 05883a796..64379e360 100644 --- a/app/models/bubble/eventable.rb +++ b/app/models/bubble/eventable.rb @@ -7,7 +7,8 @@ module Bubble::Eventable private def track_event(action, creator: Current.user, **particulars) - find_or_capture_event_summary.events.create! action: action, creator: creator, particulars: particulars + event = find_or_capture_event_summary.events.create! action: action, creator: creator, particulars: particulars + event.generate_notifications_later end def find_or_capture_event_summary diff --git a/app/models/bubble/poppable.rb b/app/models/bubble/poppable.rb index b2043d5d0..c60474d89 100644 --- a/app/models/bubble/poppable.rb +++ b/app/models/bubble/poppable.rb @@ -13,7 +13,10 @@ module Bubble::Poppable end def pop!(user: Current.user) - create_pop!(user: user) unless popped? + unless popped? + create_pop!(user: user) + track_event :popped + end end def unpop diff --git a/app/models/comment.rb b/app/models/comment.rb index 3de28081f..b4fd7d0c5 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,5 +1,5 @@ class Comment < ApplicationRecord - include Searchable, Messageable + include Messageable, Notifiable, Searchable belongs_to :creator, class_name: "User", default: -> { Current.user } diff --git a/app/models/concerns/notifiable.rb b/app/models/concerns/notifiable.rb new file mode 100644 index 000000000..8cfc7b112 --- /dev/null +++ b/app/models/concerns/notifiable.rb @@ -0,0 +1,7 @@ +module Notifiable + extend ActiveSupport::Concern + + included do + has_many :notifications, as: :resource, dependent: :destroy + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 006e25ed0..8dfceeeeb 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,5 +1,5 @@ class Event < ApplicationRecord - include Assignments, Stages + include Particulars belongs_to :creator, class_name: "User" belongs_to :summary, touch: true, class_name: "EventSummary" @@ -9,4 +9,12 @@ class Event < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :desc } scope :non_boosts, -> { where.not action: :boosted } scope :boosts, -> { where action: :boosted } + + def generate_notifications + Notifier.for(self)&.generate + end + + def generate_notifications_later + GenerateNotificationsJob.perform_later self + end end diff --git a/app/models/event/assignments.rb b/app/models/event/assignments.rb deleted file mode 100644 index 56a6b9e6e..000000000 --- a/app/models/event/assignments.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Event::Assignments - extend ActiveSupport::Concern - - included do - store_accessor :particulars, :assignee_ids - end - - def assignees - @assignees ||= account.users.where id: assignee_ids - end -end diff --git a/app/models/event/particulars.rb b/app/models/event/particulars.rb new file mode 100644 index 000000000..e21b20a7c --- /dev/null +++ b/app/models/event/particulars.rb @@ -0,0 +1,15 @@ +module Event::Particulars + extend ActiveSupport::Concern + + included do + store_accessor :particulars, :assignee_ids, :comment_id, :stage_id, :stage_name + end + + def assignees + @assignees ||= account.users.where id: assignee_ids + end + + def comment + @comment ||= Comment.find(comment_id) + end +end diff --git a/app/models/event/stages.rb b/app/models/event/stages.rb deleted file mode 100644 index c6abe0bfc..000000000 --- a/app/models/event/stages.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Event::Stages - extend ActiveSupport::Concern - - included do - store_accessor :particulars, :stage_id, :stage_name - end -end diff --git a/app/models/message.rb b/app/models/message.rb index f688f27dd..24e658ed3 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -10,7 +10,7 @@ class Message < ApplicationRecord private def created - bubble.comment_created if comment? + bubble.comment_created(comment) if comment? end def destroyed diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 000000000..531be12bb --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,13 @@ +class Notification < ApplicationRecord + belongs_to :user + belongs_to :event + belongs_to :bubble + belongs_to :resource, polymorphic: true + + scope :unread, -> { where(read: false) } + scope :ordered, -> { order(read: :desc, created_at: :desc) } + + delegate :creator, to: :event + + broadcasts_to ->(notification) { [ notification.user, :notifications ] }, inserts_by: :prepend +end diff --git a/app/models/notifier.rb b/app/models/notifier.rb new file mode 100644 index 000000000..cb64ec77c --- /dev/null +++ b/app/models/notifier.rb @@ -0,0 +1,34 @@ +class Notifier + attr_reader :event + + delegate :creator, to: :event + + class << self + def for(event) + "Notifier::#{event.action.classify}".safe_constantize&.new(event) + end + end + + def generate + recipients.map do |recipient| + Notification.create! user: recipient, event: event, bubble: bubble, resource: resource + end + end + + private + def initialize(event) + @event = event + end + + def recipients + bubble.bucket.users.without(creator) + end + + def resource + bubble + end + + def bubble + event.summary.message.bubble + end +end diff --git a/app/models/notifier/assigned.rb b/app/models/notifier/assigned.rb new file mode 100644 index 000000000..cabf8452a --- /dev/null +++ b/app/models/notifier/assigned.rb @@ -0,0 +1,6 @@ +class Notifier::Assigned < Notifier + private + def recipients + event.assignees.without(creator) + end +end diff --git a/app/models/notifier/commented.rb b/app/models/notifier/commented.rb new file mode 100644 index 000000000..42ad4e5c8 --- /dev/null +++ b/app/models/notifier/commented.rb @@ -0,0 +1,6 @@ +class Notifier::Commented < Notifier + private + def resource + event.comment + end +end diff --git a/app/models/notifier/created.rb b/app/models/notifier/created.rb new file mode 100644 index 000000000..f2724a931 --- /dev/null +++ b/app/models/notifier/created.rb @@ -0,0 +1,2 @@ +class Notifier::Created < Notifier +end diff --git a/app/models/notifier/popped.rb b/app/models/notifier/popped.rb new file mode 100644 index 000000000..f83e721ce --- /dev/null +++ b/app/models/notifier/popped.rb @@ -0,0 +1,2 @@ +class Notifier::Popped < Notifier +end diff --git a/app/models/user.rb b/app/models/user.rb index 907881cc5..cceadb922 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,8 @@ class User < ApplicationRecord has_many :assignings, foreign_key: :assigner_id, class_name: "Assignment" has_many :assigned_bubbles, through: :assignments, source: :bubble + has_many :notifications, dependent: :destroy + has_one_attached :avatar validates_presence_of :email_address diff --git a/app/views/bubbles/_bubble.html.erb b/app/views/bubbles/_bubble.html.erb index 033364ed5..f00d502ff 100644 --- a/app/views/bubbles/_bubble.html.erb +++ b/app/views/bubbles/_bubble.html.erb @@ -2,7 +2,9 @@
+ data-animation-play-class="bubble--wobble" + data-animation-play-on-load-value="true" + data-action="mouseover->animation#play">

diff --git a/app/views/bubbles/index.html.erb b/app/views/bubbles/index.html.erb index bc43bf2ab..40bc6772c 100644 --- a/app/views/bubbles/index.html.erb +++ b/app/views/bubbles/index.html.erb @@ -50,3 +50,5 @@ <%= render partial: "bubbles/list/bubble", collection: @bubbles, cached: true %> + +<%= render "notifications/tray" %> diff --git a/app/views/bubbles/show.html.erb b/app/views/bubbles/show.html.erb index 00cd19766..db4dab58f 100644 --- a/app/views/bubbles/show.html.erb +++ b/app/views/bubbles/show.html.erb @@ -65,7 +65,7 @@

-
+
<%= render "bubbles/bubble", bubble: @bubble, editing: params[:editing] %>
@@ -76,3 +76,5 @@
<%= turbo_frame_tag dom_id(@bubble, :stage_picker), src: new_bucket_bubble_stage_picker_path(@bubble.bucket, @bubble) %>
+ +<%= render "notifications/tray" %> diff --git a/app/views/buckets/index.html.erb b/app/views/buckets/index.html.erb index 9d831262b..7c2f59a91 100644 --- a/app/views/buckets/index.html.erb +++ b/app/views/buckets/index.html.erb @@ -20,3 +20,5 @@
<%= render @filters %>
+ +<%= render "notifications/tray" %> diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb new file mode 100644 index 000000000..97b884347 --- /dev/null +++ b/app/views/notifications/_notification.html.erb @@ -0,0 +1,12 @@ +<%= notification_tag notification do %> +
+
+ <%= avatar_image_tag(notification.creator) %> +
+ +
+ <%= notification_title(notification) %> + <%= notification_body(notification) %> ยท <%= time_ago_in_words(notification.created_at) %> ago +
+
+<% end %> diff --git a/app/views/notifications/_tray.html.erb b/app/views/notifications/_tray.html.erb new file mode 100644 index 000000000..71ada65cb --- /dev/null +++ b/app/views/notifications/_tray.html.erb @@ -0,0 +1,5 @@ +<%= turbo_stream_from Current.user, :notifications %> + +<%= tag.div id: "notification-tray", class: "notification-tray flex flex-column gap", data: { turbo_permanent: true } do %> + <%= turbo_frame_tag("notifications", src: notifications_path) %> +<% end %> diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb new file mode 100644 index 000000000..ee91b07e4 --- /dev/null +++ b/app/views/notifications/index.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag "notifications" do %> + <%= render @notifications %> +<% end %> diff --git a/app/views/readings/create.turbo_stream.erb b/app/views/readings/create.turbo_stream.erb new file mode 100644 index 000000000..a66c881ce --- /dev/null +++ b/app/views/readings/create.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.update :notifications do %> + <%= render @notifications %> +<% end %> diff --git a/config/importmap.rb b/config/importmap.rb index bf42e394c..87974cc7f 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -4,6 +4,7 @@ pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin "@rails/request.js", to: "@rails--request.js" # @0.0.11 pin "house", to: "house.min.js" pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/config/routes.rb b/config/routes.rb index 5f16ae681..ad0d9a2cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,12 +12,19 @@ Rails.application.routes.draw do route_for :bucket_bubble, bubble.bucket, bubble, options end + resolve "Comment" do |comment, options| + options[:anchor] = ActionView::RecordIdentifier.dom_id(comment) + route_for :bucket_bubble, comment.bubble.bucket, comment.bubble, options + end + resources :bubbles + resources :notifications resources :buckets do resources :bubbles do resources :boosts resources :comments + resource :readings, only: :create scope module: :bubbles do resource :image diff --git a/db/migrate/20250109153649_create_notifications.rb b/db/migrate/20250109153649_create_notifications.rb new file mode 100644 index 000000000..c4b1391d9 --- /dev/null +++ b/db/migrate/20250109153649_create_notifications.rb @@ -0,0 +1,15 @@ +class CreateNotifications < ActiveRecord::Migration[8.1] + def change + create_table :notifications do |t| + t.references :user, null: false, foreign_key: true + t.references :event, null: false, foreign_key: true + t.references :bubble, null: false, foreign_key: true + t.references :resource, null: false, polymorphic: true, index: true + t.boolean :read, default: false, null: false + + t.timestamps + + t.index %i[ user_id read created_at ], order: { read: :desc, created_at: :desc } + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 1d008479c..b50200c5c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do +ActiveRecord::Schema[8.1].define(version: 2025_01_09_153649) do create_table "accesses", force: :cascade do |t| t.integer "bucket_id", null: false t.integer "user_id", null: false @@ -176,6 +176,22 @@ ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do t.index ["messageable_type", "messageable_id"], name: "index_messages_on_messageable", unique: true end + create_table "notifications", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "event_id", null: false + t.integer "bubble_id", null: false + t.string "resource_type", null: false + t.integer "resource_id", null: false + t.boolean "read", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["bubble_id"], name: "index_notifications_on_bubble_id" + t.index ["event_id"], name: "index_notifications_on_event_id" + t.index ["resource_type", "resource_id"], name: "index_notifications_on_resource" + t.index ["user_id", "read", "created_at"], name: "index_notifications_on_user_id_and_read_and_created_at", order: { read: :desc, created_at: :desc } + t.index ["user_id"], name: "index_notifications_on_user_id" + end + create_table "pops", force: :cascade do |t| t.integer "bubble_id", null: false t.integer "user_id" @@ -244,6 +260,9 @@ ActiveRecord::Schema[8.1].define(version: 2024_12_09_223551) do add_foreign_key "bubbles", "workflow_stages", column: "stage_id" add_foreign_key "events", "event_summaries", column: "summary_id" add_foreign_key "messages", "bubbles" + add_foreign_key "notifications", "bubbles" + add_foreign_key "notifications", "events" + add_foreign_key "notifications", "users" add_foreign_key "pops", "bubbles" add_foreign_key "pops", "users" add_foreign_key "sessions", "users" diff --git a/test/controllers/notifications_controller_test.rb b/test/controllers/notifications_controller_test.rb new file mode 100644 index 000000000..fc4c2bfee --- /dev/null +++ b/test/controllers/notifications_controller_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class NotificationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index" do + get notifications_url + + assert_response :success + assert_select "div", text: /Layout is broken/ + end +end diff --git a/test/controllers/readings_controller_test.rb b/test/controllers/readings_controller_test.rb new file mode 100644 index 000000000..bb173b4a2 --- /dev/null +++ b/test/controllers/readings_controller_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class ReadingsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index" do + assert_changes -> { notifications(:logo_created_kevin).reload.read? }, from: false, to: true do + post bucket_bubble_readings_url(bubbles(:logo).bucket, bubbles(:logo)), as: :turbo_stream + end + + assert_response :success + end +end diff --git a/test/fixtures/events.yml b/test/fixtures/events.yml index d6be1dbe1..f1232faa6 100644 --- a/test/fixtures/events.yml +++ b/test/fixtures/events.yml @@ -54,6 +54,13 @@ layout_created: summary: layout_initial_activity created_at: <%= 1.week.ago %> +layout_commented: + creator: david + action: commented + summary: layout_initial_activity + particulars: <%= { comment_id: ActiveRecord::FixtureSet.identify(:layout_overflowing_david) }.to_json %> + created_at: <%= 1.week.ago %> + layout_assignment_jz: creator: david action: assigned @@ -72,3 +79,9 @@ shipping_created: action: created summary: shipping_initial_activity created_at: <%= 1.week.ago %> + +shipping_popped: + creator: kevin + action: popped + summary: shipping_initial_activity + created_at: <%= 2.days.ago %> diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml new file mode 100644 index 000000000..c73954558 --- /dev/null +++ b/test/fixtures/notifications.yml @@ -0,0 +1,15 @@ +logo_created_kevin: + user: kevin + event: logo_created + bubble: logo + resource: logo (Bubble) + read: false + created_at: <%= 1.week.ago %> + +layout_commented_kevin: + user: kevin + event: layout_commented + bubble: layout + resource: layout_overflowing_david (Comment) + read: false + created_at: <%= 1.week.ago %> diff --git a/test/models/bubble/poppable_test.rb b/test/models/bubble/poppable_test.rb index 5ce612309..cff8d1a38 100644 --- a/test/models/bubble/poppable_test.rb +++ b/test/models/bubble/poppable_test.rb @@ -9,7 +9,9 @@ class Bubble::PoppableTest < ActiveSupport::TestCase test "popping" do assert_not bubbles(:logo).popped? - bubbles(:logo).pop! + with_current_user(:kevin) do + bubbles(:logo).pop! + end assert bubbles(:logo).popped? end diff --git a/test/models/bubble_test.rb b/test/models/bubble_test.rb index c8f4c3c16..33466acbd 100644 --- a/test/models/bubble_test.rb +++ b/test/models/bubble_test.rb @@ -6,11 +6,11 @@ class BubbleTest < ActiveSupport::TestCase end test "capturing messages" do - assert_difference "bubbles(:logo).messages.count", +1 do + assert_difference "bubbles(:logo).messages.comments.count", +1 do bubbles(:logo).capture Comment.new(body: "Agreed.") end - assert_equal "Agreed.", bubbles(:logo).messages.last.messageable.body.to_plain_text.chomp + assert_equal "Agreed.", bubbles(:logo).messages.comments.last.messageable.body.to_plain_text.chomp end test "boosting" do diff --git a/test/models/notifier/assigned_test.rb b/test/models/notifier/assigned_test.rb new file mode 100644 index 000000000..c78adce5f --- /dev/null +++ b/test/models/notifier/assigned_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Notifier::AssignedTest < ActiveSupport::TestCase + test "creates a notification for the assignee" do + notifications = Notifier.for(events(:logo_assignment_km)).generate + + assert_equal [ users(:kevin) ], notifications.map(&:user) + end + + test "does not notify for self-assignments" do + event = EventSummary.last.events.create! action: :assigned, + creator: users(:kevin), + particulars: { assignee_ids: [ users(:kevin).id ] } + + assert_no_difference -> { Notification.count } do + Notifier.for(event).generate + end + end + + test "links to the bubble" do + Notifier.for(events(:logo_assignment_km)).generate + + assert_equal bubbles(:logo), Notification.last.resource + end +end diff --git a/test/models/notifier/commented_test.rb b/test/models/notifier/commented_test.rb new file mode 100644 index 000000000..d106b4e40 --- /dev/null +++ b/test/models/notifier/commented_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class Notifier::CommentedTest < ActiveSupport::TestCase + test "creates a notification for each recipient" do + notifications = Notifier.for(events(:layout_commented)).generate + + assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort + end + + test "links to the bubble" do + Notifier.for(events(:layout_commented)).generate + + assert_equal comments(:layout_overflowing_david), Notification.last.resource + end +end diff --git a/test/models/notifier/created_test.rb b/test/models/notifier/created_test.rb new file mode 100644 index 000000000..0c7aaa546 --- /dev/null +++ b/test/models/notifier/created_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class Notifier::CreatedTest < ActiveSupport::TestCase + test "creates a notification for each recipient" do + notifications = Notifier.for(events(:logo_created)).generate + + assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort + end + + test "links to the bubble" do + Notifier.for(events(:logo_created)).generate + + assert_equal bubbles(:logo), Notification.last.resource + end +end diff --git a/test/models/notifier/popped_test.rb b/test/models/notifier/popped_test.rb new file mode 100644 index 000000000..300568114 --- /dev/null +++ b/test/models/notifier/popped_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class Notifier::PoppedTest < ActiveSupport::TestCase + test "creates a notification for each recipient" do + notifications = Notifier.for(events(:shipping_popped)).generate + + assert_equal users(:david, :jz).sort, notifications.map(&:user).sort + end + + test "links to the bubble" do + Notifier.for(events(:shipping_popped)).generate + + assert_equal bubbles(:shipping), Notification.last.resource + end +end diff --git a/test/models/notifier_test.rb b/test/models/notifier_test.rb new file mode 100644 index 000000000..62baf0c2d --- /dev/null +++ b/test/models/notifier_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class NotifierTest < ActiveSupport::TestCase + test "for returns the matching notifier class for the event" do + assert_kind_of Notifier::Created, Notifier.for(events(:logo_created)) + end + + test "for does not raise an error when the event is not notifiable" do + assert_nothing_raised do + assert_no_difference -> { Notification.count } do + Notifier.for(events(:logo_boost_dhh)) + end + end + end +end diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index 598afbd80..5666c5b28 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -13,4 +13,12 @@ module SessionTestHelper delete session_url assert_not cookies[:session_token].present? end + + def with_current_user(user) + user = users(user) unless user.is_a? User + Current.session = Session.new(user: user) + yield + ensure + Current.reset_all + end end diff --git a/vendor/javascript/@rails--request.js b/vendor/javascript/@rails--request.js new file mode 100644 index 000000000..b85d5c78a --- /dev/null +++ b/vendor/javascript/@rails--request.js @@ -0,0 +1,4 @@ +// @rails/request.js@0.0.11 downloaded from https://ga.jspm.io/npm:@rails/request.js@0.0.11/src/index.js + +class FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get("WWW-Authenticate")}get contentType(){const t=this.response.headers.get("Content-Type")||"";return t.replace(/;.*$/,"")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`))}get json(){return this.contentType.match(/^application\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\/vnd\.turbo-stream\.html/)}get isScript(){return this.contentType.match(/\b(?:java|ecma)script\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));{const t=document.createElement("script");const e=document.querySelector("meta[name=csp-nonce]");const n=e&&e.content;n&&t.setAttribute("nonce",n);t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function getCookie(t){const e=document.cookie?document.cookie.split("; "):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split("=").slice(1).join("=");if(t)return decodeURIComponent(t)}}function compact(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function metaContent(t){const e=document.head.querySelector(`meta[name="${t}"]`);return e&&e.content}function stringEntriesFromFormData(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n==="string"?[[e,n]]:[])),[])}function mergeEntries(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes("[]")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=this.responseKind==="turbo-stream"&&window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith("http:"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect}}get headers(){const t={"X-Requested-With":"XMLHttpRequest","Content-Type":this.contentType,Accept:this.accept};this.sameHostname()&&(t["X-CSRF-Token"]=this.csrfToken);return compact(Object.assign(t,this.additionalHeaders))}get csrfToken(){return getCookie(metaContent("csrf-param"))||metaContent("csrf-token")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:"application/json"}get accept(){switch(this.responseKind){case"html":return"text/html, application/xhtml+xml";case"turbo-stream":return"text/vnd.turbo-stream.html, text/html, application/xhtml+xml";case"json":return"application/json, application/vnd.api+json";case"script":return"text/javascript, application/javascript";default:return"*/*"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split("?")[1]||"").split("#")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?stringEntriesFromFormData(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});mergeEntries(e,n);const s=e.toString();return s.length>0?`?${s}`:""}get url(){return this.originalUrl.split("?")[0].split("#")[0]+this.query}get responseKind(){return this.options.responseKind||"html"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||"follow"}get credentials(){return this.options.credentials||"same-origin"}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)==="[object String]";const e=this.headers["Content-Type"]==="application/json";return e&&!t?JSON.stringify(this.body):this.body}}async function get(t,e){const n=new FetchRequest("get",t,e);return n.perform()}async function post(t,e){const n=new FetchRequest("post",t,e);return n.perform()}async function put(t,e){const n=new FetchRequest("put",t,e);return n.perform()}async function patch(t,e){const n=new FetchRequest("patch",t,e);return n.perform()}async function destroy(t,e){const n=new FetchRequest("delete",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,destroy,get,patch,post,put}; +