Spike events system

This commit is contained in:
Jose Farias
2024-10-11 20:14:09 -05:00
parent 9e2ca7ad4b
commit ec7fc75054
46 changed files with 436 additions and 146 deletions
+1 -1
View File
@@ -2,6 +2,6 @@ class BoostsController < ApplicationController
include BubbleScoped, BucketScoped
def create
@bubble.boosts.create!
@bubble.boost!
end
end
+1 -6
View File
@@ -2,12 +2,7 @@ class CommentsController < ApplicationController
include BubbleScoped, BucketScoped
def create
@bubble.comments.create!(comment_params)
@bubble.comment! params.dig(:comment, :body).presence
redirect_to bucket_bubble_url(@bucket, @bubble)
end
private
def comment_params
params.require(:comment).permit(:body)
end
end
+1 -1
View File
@@ -8,7 +8,7 @@ module BubblesHelper
end
def bubble_size(bubble)
activity = bubble.boosts.size + bubble.comments.size
activity = bubble.boost_count + bubble.comments.count
rank =
case activity
when 0..5 then "one"
-80
View File
@@ -1,80 +0,0 @@
module CommentsHelper
def render_comments_and_boosts(bubble)
combined_collection = combine_and_sort_items(bubble)
safe_join([
render_creator_summary(bubble, combined_collection),
render_remaining_items(combined_collection)
])
end
private
def combine_and_sort_items(bubble)
(bubble.comments + bubble.boosts + bubble.assignments).sort_by(&:created_at)
end
def render_creator_summary(bubble, combined_collection)
content_tag(:div, class: "comment--upvotes flex-inline flex-wrap align-start gap fill-white border-radius center position-relative") do
[
creator_info(bubble),
initial_assignment_info(combined_collection),
render_initial_boosts(combined_collection)
].compact.join(", ").html_safe
end
end
def creator_info(bubble)
"Added by #{bubble.creator.name} #{time_ago_in_words(bubble.created_at)} ago"
end
def initial_assignment_info(combined_collection)
initial_assignment = combined_collection.find { |item| item.is_a?(Assignment) }
"assigned to #{initial_assignment.assignee.name}" if initial_assignment
end
def render_initial_boosts(combined_collection)
grouped_boosts = combined_collection.take_while { |item| item.is_a?(Boost) }
return if grouped_boosts.empty?
user_boosts = grouped_boosts.group_by(&:creator).transform_values(&:count)
boost_summaries = user_boosts.map { |user, count| "#{user.name} +#{count}" }
boost_summaries.to_sentence
end
def render_remaining_items(combined_collection)
initial_count = combined_collection.take_while { |item| !item.is_a?(Comment) }.count
items = combined_collection.drop(initial_count)
safe_join(items.chunk_while { |i, j| grouped_item?(i) && grouped_item?(j) }.map do |chunk|
if chunk.first.is_a?(Comment)
render "comments/comment", comment: chunk.first
else
render_grouped_items(chunk)
end
end)
end
def grouped_item?(item)
item.is_a?(Boost) || item.is_a?(Assignment)
end
def render_grouped_items(items)
return if items.empty?
content_tag(:div, class: "comment--upvotes flex-inline flex-wrap align-start gap fill-white border-radius center position-relative") do
[
render_grouped_boosts(items.select { |item| item.is_a?(Boost) }),
render_grouped_assignments(items.select { |item| item.is_a?(Assignment) })
].flatten.compact.to_sentence.html_safe
end
end
def render_grouped_boosts(boosts)
return if boosts.empty?
boosts.group_by(&:creator).map { |user, user_boosts| "#{user.name} +#{user_boosts.count}" }
end
def render_grouped_assignments(assignments)
assignments.map { |assignment| "Assigned to #{assignment.assignee.name} #{time_ago_in_words(assignment.created_at)} ago" }
end
end
@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
import { nextFrame } from "helpers"
export default class extends Controller {
static classes = [ "play" ]
@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus"
import { debounce } from "helpers/timing_helpers"
import { debounce } from "helpers"
export default class extends Controller {
static targets = [ "list" ]
@@ -0,0 +1,14 @@
import { Controller } from "@hotwired/stimulus"
import { current } from "helpers"
export default class extends Controller {
static classes = [ "myComment" ]
connect() {
this.#myComments.forEach(comment => comment.classList.add(this.myCommentClass))
}
get #myComments() {
return this.element.querySelectorAll(`.comment[data-creator-id='${current.user.id}']`)
}
}
+25
View File
@@ -0,0 +1,25 @@
// On-demand JavaScript objects from "current" HTML <meta> elements. Example:
//
// <meta name="current-identity-id" content="123">
// <meta name="current-identity-time-zone-name" content="Central Time (US & Canada)">
//
// >> current.identity
// => { id: "123", timeZoneName: "Central Time (US & Canada)" }
//
// >> current.foo
// => {}
export const current = new Proxy({}, {
get(_target, propertyName) {
const result = {}
const prefix = `current-${propertyName}-`
for (const { name, content } of document.head.querySelectorAll(`meta[name^=${prefix}]`)) {
const key = camelize(name.slice(prefix.length))
result[key] = content
}
return result
}
})
function camelize(string) {
return string.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase())
}
+2
View File
@@ -0,0 +1,2 @@
export * from "helpers/current_helpers"
export * from "helpers/timing_helpers"
-4
View File
@@ -1,4 +0,0 @@
class Boost < ApplicationRecord
belongs_to :bubble
belongs_to :creator, class_name: "User", default: -> { Current.user }
end
+2 -5
View File
@@ -1,12 +1,9 @@
class Bubble < ApplicationRecord
include Assignable, Colored, Poppable, Searchable, Taggable
include Assignable, Boostable, Colored, Commentable, Eventable, Poppable, Searchable, Taggable, Threaded
belongs_to :bucket
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :comments, dependent: :destroy
has_many :boosts, dependent: :destroy
has_one_attached :image, dependent: :purge_later
searchable_by :title, using: :bubbles_search_index
@@ -14,7 +11,7 @@ class Bubble < ApplicationRecord
before_save :set_default_title
scope :reverse_chronologically, -> { order(created_at: :desc, id: :desc) }
scope :ordered_by_activity, -> { left_joins(:comments, :boosts).group(:id).order(Arel.sql("COUNT(comments.id) + COUNT(boosts.id) DESC")) }
scope :ordered_by_activity, -> { left_joins(:comments).group(:id).order(Arel.sql("COUNT(comments.id) + boost_count DESC")) }
scope :mentioning, ->(query) do
bubbles = search(query).select(:id).to_sql
+4 -3
View File
@@ -2,7 +2,7 @@ module Bubble::Assignable
extend ActiveSupport::Concern
included do
has_many :assignments, dependent: :destroy
has_many :assignments, dependent: :delete_all
has_many :assignees, through: :assignments
has_many :assigners, through: :assignments
@@ -12,8 +12,9 @@ module Bubble::Assignable
end
def assign(users, assigner: Current.user)
(Array(users) - assignees).tap do |users|
users.each { |user| assignments.create!(assignee: user, assigner: assigner) }
transaction do
Assignment.insert_all Array(users).collect { |user| { assignee_id: user.id, assigner_id: assigner.id, bubble_id: id } }
track_event :assigned, assignee_ids: Array(users).map(&:id)
end
end
end
+10
View File
@@ -0,0 +1,10 @@
module Bubble::Boostable
extend ActiveSupport::Concern
def boost!
transaction do
increment! :boost_count
track_event :boosted, boost_count:
end
end
end
+11
View File
@@ -0,0 +1,11 @@
module Bubble::Commentable
extend ActiveSupport::Concern
included do
has_many :comments, dependent: :delete_all
end
def comment!(body)
comments.create! body:
end
end
+14
View File
@@ -0,0 +1,14 @@
module Bubble::Eventable
extend ActiveSupport::Concern
included do
has_many :events, dependent: :delete_all
after_create -> { track_event :created }
end
private
def track_event(action, creator: Current.user, **particulars)
events.create! action:, creator:, particulars:
end
end
+39
View File
@@ -0,0 +1,39 @@
class Bubble::Thread
attr_reader :bubble
def initialize(bubble)
@bubble = bubble
end
def entries
sorted_entries.chunk_while { |a, b| consecutive_events?(a, b) }.map.with_index { |entries, index| roll_up(entries, index) }
end
def to_partial_path
"bubbles/threads/thread"
end
def cache_key
ActiveSupport::Cache.expand_cache_key bubble, :thread
end
private
delegate :events, :comments, to: :bubble, private: true
def sorted_entries
(events + comments).sort_by(&:created_at)
end
def consecutive_events?(a, b)
[ a, b ] in [ Event, Event ]
end
def roll_up(entries, index)
case entries.first
when Comment
entries.sole
when Event
Rollup.new self, entries, first_position: index.zero?
end
end
end
+59
View File
@@ -0,0 +1,59 @@
class Bubble::Thread::Rollup
def initialize(thread, entries, first_position: false)
@thread = thread
@entries = entries
@first_position = first_position
end
def body
collapsed_entries.map { |entry, chunk_size| summarize(entry, chunk_size) }.to_sentence.upcase_first
end
def to_partial_path
"bubbles/threads/rollup"
end
def cache_key
ActiveSupport::Cache.expand_cache_key [ thread, entries.first, entries.last ], "rollup"
end
private
attr_reader :thread, :entries, :first_position
delegate :time_ago_in_words, to: "ApplicationController.helpers", private: true
def first_position?
first_position
end
def collapsed_entries
sorted_entries.chunk_while { |a, b| equivalent_boosts?(a, b) }.map { |chunk| [ chunk.last, chunk.size ] }
end
def sorted_entries
entries.sort_by do |entry|
case entry.action
when "created" then [ 1, entry.created_at ]
when "assigned" then [ 2, entry.created_at ]
when "boosted" then [ 3, entry.creator, entry.created_at ]
end
end
end
def equivalent_boosts?(a, b)
a.action == "boosted" && a.slice(:action, :creator_id) == b.slice(:action, :creator_id)
end
def summarize(entry, chunk_size)
case entry.action
when "created"
"added by #{entry.creator.name} #{time_ago_in_words(entry.created_at)} ago"
when "assigned"
summary = "assigned to #{entry.assignee_names.to_sentence}"
summary += " #{time_ago_in_words(entry.created_at)} ago" unless first_position?
summary
when "boosted"
"#{entry.creator.name} +#{chunk_size}"
end
end
end
+5
View File
@@ -0,0 +1,5 @@
module Bubble::Threaded
def thread
Bubble::Thread.new self
end
end
+6 -2
View File
@@ -1,6 +1,10 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :session, :user
delegate :user, to: :session, allow_nil: true
delegate :account, to: :user, allow_nil: true
def session=(session)
super
self.user = session.user
end
end
+10
View File
@@ -0,0 +1,10 @@
class Event < ApplicationRecord
THREADABLE_ACTIONS = %w[ assigned boosted created ]
include Assignments
belongs_to :creator, class_name: "User"
belongs_to :bubble
scope :threadable, -> { where action: THREADABLE_ACTIONS }
end
+16
View File
@@ -0,0 +1,16 @@
module Event::Assignments
extend ActiveSupport::Concern
included do
store_accessor :particulars, :assignee_ids
end
def assignee_names
assignees.map &:name
end
private
def assignees
@assignees ||= creator.account.users.find assignee_ids
end
end
+4 -4
View File
@@ -1,6 +1,6 @@
<%= button_to bucket_bubble_boosts_path(bubble.bucket, bubble),
class: "btn btn--plain",
data: { action: "toggle-class#toggle" },
form_class: "full-width" do %>
+ <%= bubble.boosts.size if bubble.boosts.any? %>
class: "btn btn--plain",
data: { action: "toggle-class#toggle" },
form_class: "full-width" do %>
+ <%= bubble.boost_count if bubble.boost_count.positive? %>
<% end %>
+4
View File
@@ -1,3 +1,7 @@
<%= turbo_stream.update dom_id(@bubble, :boosts) do %>
<%= render "boosts/boosts", bubble: @bubble %>
<% end %>
<%= turbo_stream.replace dom_id(@bubble, :thread) do %>
<%= render @bubble.thread %>
<% end %>
+1 -4
View File
@@ -25,7 +25,4 @@
<%= render "bubbles/bubble", bubble: @bubble %>
</div>
<section class="comments align-center center borderless margin flex flex-column gap-half" style="--bubble-color: <%= @bubble.color %>;">
<%= render_comments_and_boosts(@bubble) %>
<%= render "comments/new", bubble: @bubble %>
</section>
<%= render @bubble.thread %>
@@ -0,0 +1,3 @@
<%# Template Dependency: comments/comment %>
<%# Template Dependency: bubbles/threads/rollup %>
<%= render entry %>
@@ -0,0 +1,3 @@
<div class="comment--upvotes flex-inline flex-wrap align-start gap fill-white border-radius center position-relative">
<%= rollup.body %>
</div>
@@ -0,0 +1,13 @@
<% bubble = thread.bubble %>
<section id="<%= dom_id(bubble, :thread) %>"
class="comments align-center center borderless margin flex flex-column gap-half"
style="--bubble-color: <%= bubble.color %>;"
data-controller="thread"
data-thread-my-comment-class="comment--mine">
<% cache thread do %>
<%= render partial: "bubbles/threads/entry", collection: thread.entries, cache: true %>
<% end %>
<%= render "comments/new", bubble: bubble %>
</section>
+1 -1
View File
@@ -20,7 +20,7 @@
<%= link_to bucket_bubbles_path(bucket), class: "windshield__container flex justify-center align-center position-relative" do %>
<div class="windshield bucket__windshield flex flex-wrap gap justify-center align-end" style="view-transition-name: windshield_<%= bucket.id %>">
<% bucket.bubbles.not_popped.left_joins(:comments, :boosts).group(:id).order(Arel.sql("COUNT(comments.id) + COUNT(boosts.id) DESC")).limit(10).each do |bubble| %>
<% bucket.bubbles.not_popped.ordered_by_activity.limit(10).each do |bubble| %>
<div class="bubble" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>">
<svg class="bubble__svg" style="fill: <%= bubble.color %>; stroke: <%= bubble.color %>;" viewBox="0 0 990 990" xmlns="http://www.w3.org/2000/svg">
<path d="m0 0h990v990h-990z" fill="none" stroke="none" />
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="comment flex align-start full-width <%= "comment--mine" if comment.creator == Current.user %>" id="<%= dom_id(comment) %>">
<div id="<%= dom_id(comment) %>" class="comment flex align-start full-width" data-creator-id="<%= comment.creator.id %>">
<figure class="comment__avatar flex-item-no-shrink">
<%= avatar_tag comment.creator, loading: :lazy %>
</figure>
+4
View File
@@ -9,6 +9,10 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<% if Current.user %>
<meta name="current-user-id" content="<%= Current.user.id %>">
<% end %>
<%= yield :head %>
<%= stylesheet_link_tag :all, "data-turbo-track": "reload" %>
@@ -0,0 +1,5 @@
class AddUniqueIndexToAssignments < ActiveRecord::Migration[8.0]
def change
add_index :assignments, %i[ assignee_id bubble_id ], unique: true
end
end
@@ -0,0 +1,5 @@
class MakeCommentBodiesNotNull < ActiveRecord::Migration[8.0]
def change
change_column_null :comments, :body, false
end
end
@@ -0,0 +1,14 @@
class CreateEvents < ActiveRecord::Migration[8.0]
def change
create_table :events do |t|
t.references :bubble, null: false, index: false
t.references :creator, null: false
t.json :particulars, default: {}
t.string :action, null: false
t.timestamps
end
add_index :events, %i[ bubble_id action ], name: "index_events_on_bubble_id_and_action"
end
end
+5
View File
@@ -0,0 +1,5 @@
class DropBoosts < ActiveRecord::Migration[8.0]
def change
drop_table :boosts
end
end
@@ -0,0 +1,5 @@
class AddBoostCountToBubbles < ActiveRecord::Migration[8.0]
def change
add_column :bubbles, :boost_count, :integer, null: false, default: 0
end
end
@@ -0,0 +1,5 @@
class RemoveAssignmentsIndexOnAssigneeId < ActiveRecord::Migration[8.0]
def change
remove_index :assignments, :assignee_id
end
end
Generated
+15 -11
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2024_10_02_163242) do
ActiveRecord::Schema[8.0].define(version: 2024_10_09_211414) do
create_table "accesses", force: :cascade do |t|
t.integer "bucket_id", null: false
t.integer "user_id", null: false
@@ -64,18 +64,10 @@ ActiveRecord::Schema[8.0].define(version: 2024_10_02_163242) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "assigner_id", null: false
t.index ["assignee_id"], name: "index_assignments_on_assignee_id"
t.index ["assignee_id", "bubble_id"], name: "index_assignments_on_assignee_id_and_bubble_id", unique: true
t.index ["bubble_id"], name: "index_assignments_on_bubble_id"
end
create_table "boosts", force: :cascade do |t|
t.integer "creator_id", null: false
t.integer "bubble_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["bubble_id"], name: "index_boosts_on_bubble_id"
end
create_table "bubbles", force: :cascade do |t|
t.string "title"
t.string "color"
@@ -84,6 +76,7 @@ ActiveRecord::Schema[8.0].define(version: 2024_10_02_163242) do
t.integer "creator_id", null: false
t.date "due_on"
t.integer "bucket_id", null: false
t.integer "boost_count", default: 0, null: false
t.index ["bucket_id"], name: "index_bubbles_on_bucket_id"
end
@@ -98,13 +91,24 @@ ActiveRecord::Schema[8.0].define(version: 2024_10_02_163242) do
end
create_table "comments", force: :cascade do |t|
t.text "body"
t.text "body", null: false
t.integer "creator_id", null: false
t.integer "bubble_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "events", force: :cascade do |t|
t.integer "bubble_id", null: false
t.integer "creator_id", null: false
t.json "particulars", default: {}
t.string "action", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["bubble_id", "action"], name: "index_events_on_bubble_id_and_action"
t.index ["creator_id"], name: "index_events_on_creator_id"
end
create_table "pops", force: :cascade do |t|
t.integer "bubble_id", null: false
t.integer "user_id"
View File
+11 -3
View File
@@ -1,7 +1,15 @@
require "test_helper"
class BoostsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
setup do
sign_in_as :kevin
end
test "create" do
assert_difference "bubbles(:logo).reload.boost_count", +1 do
post bucket_bubble_boosts_url(buckets(:writebook), bubbles(:logo), format: :turbo_stream)
end
assert_turbo_stream action: :update, target: dom_id(bubbles(:logo), :boosts)
end
end
+11 -3
View File
@@ -1,7 +1,15 @@
require "test_helper"
class CommentsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
setup do
sign_in_as :kevin
end
test "create" do
assert_difference "bubbles(:logo).comments.count", +1 do
post bucket_bubble_comments_url(buckets(:writebook), bubbles(:logo), params: { comment: { body: "Agreed." } })
end
assert_redirected_to bucket_bubble_url(buckets(:writebook), bubbles(:logo))
end
end
+7
View File
@@ -1,7 +1,14 @@
logo_jz:
assigner: david
assignee: jz
bubble: logo
created_at: <%= 1.week.ago %>
logo_kevin:
assigner: david
assignee: kevin
bubble: logo
created_at: <%= 1.day.ago %>
layout_jz:
assigner: david
-15
View File
@@ -1,15 +0,0 @@
logo_jz_one:
bubble: logo
creator: jz
logo_jz_two:
bubble: logo
creator: jz
logo_kevin:
bubble: logo
creator: kevin
layout_jz:
bubble: layout
creator: jz
+5
View File
@@ -4,21 +4,26 @@ logo:
title: The logo isn't big enough
color: "#ED8008"
due_on: <%= 3.days.from_now %>
created_at: <%= 1.week.ago %>
boost_count: 4
layout:
bucket: writebook
creator: david
title: Layout is broken
color: "#698F9C"
created_at: <%= 1.week.ago %>
text:
bucket: writebook
creator: kevin
title: The text is too small
color: "#3B4B59"
created_at: <%= 1.week.ago %>
shipping:
bucket: writebook
creator: kevin
title: We need to ship the app
color: "#ED8008"
created_at: <%= 1.week.ago %>
+1
View File
@@ -2,6 +2,7 @@ logo_agreement_jz:
body: I agree.
creator: jz
bubble: logo
created_at: <%= 2.days.ago %>
logo_agreement_kevin:
body: Same, lets do it.
+66
View File
@@ -0,0 +1,66 @@
logo_created:
creator: david
bubble: logo
action: created
created_at: <%= 1.week.ago %>
logo_assignment_jz:
creator: david
bubble: logo
action: assigned
particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %>
created_at: <%= 1.week.ago %>
logo_assignment_km:
creator: david
bubble: logo
action: assigned
particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:kevin) ] }.to_json %>
created_at: <%= 1.day.ago %>
logo_boost_km1:
creator: kevin
bubble: logo
action: boosted
created_at: <%= 1.day.ago %>
logo_boost_km2:
creator: kevin
bubble: logo
action: boosted
created_at: <%= 1.day.ago %>
logo_boost_jz1:
creator: jz
bubble: logo
action: boosted
created_at: <%= 1.day.ago %>
logo_boost_jz2:
creator: jz
bubble: logo
action: boosted
layout_created:
creator: david
bubble: layout
action: created
created_at: <%= 1.week.ago %>
layout_assignment_jz:
creator: david
bubble: layout
action: assigned
particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %>
text_created:
creator: kevin
bubble: text
action: created
created_at: <%= 1.week.ago %>
shipping_created:
creator: kevin
bubble: shipping
action: created
created_at: <%= 1.week.ago %>
+25
View File
@@ -1,6 +1,31 @@
require "test_helper"
class BubbleTest < ActiveSupport::TestCase
setup do
Current.user = users(:kevin)
end
test "boosting" do
assert_difference %w[ bubbles(:logo).boost_count bubbles(:logo).events.count ], +1 do
bubbles(:logo).boost!
end
end
test "commenting" do
bubbles(:logo).comment! "Agreed."
assert_equal "Agreed.", bubbles(:logo).comments.last.body
end
test "assigning" do
bubbles(:logo).assign users(:david)
assert_equal users(:kevin, :jz, :david), bubbles(:logo).assignees
assert_equal users(:david, :kevin), bubbles(:logo).assigners.uniq
assert_equal [ users(:david).id ], bubbles(:logo).events.last.assignee_ids
assert_equal [ "David" ], bubbles(:logo).events.last.assignee_names
end
test "searchable by title" do
bubble = buckets(:writebook).bubbles.create! title: "Insufficient haggis", creator: users(:kevin)