Merge branch 'main' into two-columns-refactor

* main:
  Fix image icons
  Revert "Merge pull request #345 from basecamp/cascade-layers"
  Fine tune notifications states
  Remove empty helper
  Use an icon for the button
  Show button on collection page and notif settings
  Add involvement types to accesses
  Extract a couple components
  Add cascade layers to all CSS files
This commit is contained in:
Jason Zimdars
2025-04-03 19:30:37 -05:00
32 changed files with 177 additions and 203 deletions
@@ -0,0 +1 @@
<svg enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m15.9 19.5-11-11c-.3.8-.4 1.7-.4 2.6v4.9c0 .8-.7 1.5-1.5 1.5-.6 0-1 .4-1 1s.4 1 1 1z"/><path d="m9.5 21.2v.3c0 1.4 1.1 2.5 2.5 2.5s2.5-1.1 2.5-2.5c0-.1 0-.2 0-.3s-.1-.2-.2-.2h-4.5c-.2 0-.3.1-.3.2z"/><path d="m23.7 23.7c.4-.4.4-1 0-1.4l-2.8-2.8h.1c.6 0 1-.4 1-1s-.4-1-1-1c-.8 0-1.5-.7-1.5-1.5v-4.9c.1-3.9-2.6-7.4-6.5-8.1v-2c0-.6-.4-1-1-1s-1 .4-1 1v2.1c-1.6.3-3.1 1-4.3 2.3l-5-5c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4l22 22c.2.2.4.3.7.3.3-.1.5-.2.7-.4z"/></svg>

After

Width:  |  Height:  |  Size: 557 B

@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m21 17.5a1.5 1.5 0 0 1 -1.5-1.5v-4.862a7.957 7.957 0 0 0 -6.5-8.065v-2.073a1 1 0 0 0 -2 0v2.073a7.957 7.957 0 0 0 -6.5 8.065v4.862a1.5 1.5 0 0 1 -1.5 1.5 1 1 0 0 0 0 2h18a1 1 0 0 0 0-2z"/><path d="m14.236 21h-4.472a.25.25 0 0 0 -.248.222 2.319 2.319 0 0 0 -.016.278 2.5 2.5 0 1 0 5 0 2.319 2.319 0 0 0 -.016-.278.248.248 0 0 0 -.248-.222z"/></svg>

After

Width:  |  Height:  |  Size: 416 B

@@ -0,0 +1 @@
<svg enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m7 16.4c0-5 4-9 9-9 1.1 0 2.2.2 3.2.6-.6-1.8-1.8-3.3-3.3-4.4-.7-.5-1.5-.8-2.3-.9v-1.2c-.1-.8-.8-1.5-1.6-1.5s-1.5.7-1.5 1.5v1.1c-1 .2-2 .6-2.9 1.3-2.2 1.8-3.4 4.4-3.3 7.2v.8c0 2.5-.6 5.1-1.7 7.3-.1.2 0 .5.2.7 0 .1.1.1.2.1h4.7c-.4-1.1-.7-2.3-.7-3.6z"/><path d="m22.1 11.4c-1.6-1.6-3.9-2.4-6.2-2.3-4.1 0-7.4 3.3-7.4 7.4s3.3 7.4 7.4 7.4c.7 0 1.5-.1 2.2-.3.5-.1.8-.7.6-1.2s-.7-.8-1.2-.6c-2.9.9-6-.7-6.9-3.6s.7-6 3.6-6.9c.5-.2 1.1-.2 1.6-.2 1.8-.1 3.5.5 4.8 1.7.9.9 1.4 2.1 1.4 3.4s-.6 2.6-1.5 2.6c-.1 0-.2 0-.3 0v-5c0-.5-.4-.9-.9-.9-.3 0-.6.2-.8.5-.7-.6-1.6-.9-2.5-.9-2.3 0-4.1 1.8-4.1 4.1s1.8 4.1 4.1 4.1c1 0 1.9-.3 2.6-1 .1.2.2.3.3.4.5.4 1.1.6 1.7.6 2 0 3.4-1.9 3.4-4.5 0-1.8-.7-3.5-1.9-4.8zm-6.2 7.4c-1.2 0-2.2-1-2.2-2.2s1-2.2 2.2-2.2 2.2 1 2.2 2.2-1 2.2-2.2 2.2z"/></svg>

After

Width:  |  Height:  |  Size: 873 B

+7
View File
@@ -12,6 +12,10 @@
user-select: none;
}
img.icon {
background: none;
}
.icon--activity { --svg: url("activity.svg "); }
.icon--add { --svg: url("add.svg "); }
.icon--alert { --svg: url("alert.svg "); }
@@ -49,6 +53,9 @@
.icon--logout { --svg: url("logout.svg "); }
.icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); }
.icon--minus { --svg: url("minus.svg "); }
.icon--notification-bell-access-only { --svg: url("notification-bell-access-only.svg "); }
.icon--notification-bell-everything { --svg: url("notification-bell-everything.svg "); }
.icon--notification-bell-watching { --svg: url("notification-bell-watching.svg "); }
.icon--password { --svg: url("password.svg "); }
.icon--pencil { --svg: url("pencil.svg "); }
.icon--person { --svg: url("person.svg "); }
@@ -1,10 +1,6 @@
class Bubbles::WatchesController < ApplicationController
include BubbleScoped, BucketScoped
def show
@watchers = @bubble.watchers_and_subscribers.sorted_with_user_first(Current.user)
end
def create
set_watching_and_redirect(true)
end
@@ -0,0 +1,7 @@
class Buckets::InvolvementsController < ApplicationController
include BucketScoped
def update
@bucket.access_for(Current.user).update!(involvement: params[:involvement])
end
end
@@ -1,11 +0,0 @@
class Buckets::SubscriptionsController < ApplicationController
include BucketScoped
def update
if params[:subscribed]
@bucket.subscribe(Current.user)
else
@bucket.unsubscribe(Current.user)
end
end
end
+32
View File
@@ -12,4 +12,36 @@ module AccessesHelper
locals: { selected: selected },
cached: ->(user) { [ user, selected ] }
end
def access_involvement_advance_button(bucket, user)
access = bucket.access_for(user)
turbo_frame_tag dom_id(bucket, :involvement_button) do
button_to bucket_involvement_path(bucket), method: :put,
aria: { labelledby: dom_id(bucket, :involvement_label) },
class: [ "btn", { "btn--reversed": access.involvement == "watching" || access.involvement == "everything" } ],
params: { involvement: next_involvement(access.involvement) },
title: involvement_access_label(bucket, access.involvement) do
icon_tag("notification-bell-#{access.involvement.dasherize}") +
tag.span(involvement_access_label(bucket, access.involvement), class: "for-screen-reader", id: dom_id(bucket, :involvement_label))
end
end
end
private
def next_involvement(involvement)
order = %w[ everything watching access_only ]
order[(order.index(involvement.to_s) + 1) % order.size]
end
def involvement_access_label(bucket, involvement)
case involvement
when "access_only"
"Notifications are off for #{bucket.name}"
when "everything"
"Notifying me about everything in #{bucket.name}"
when "watching"
"Notifying me only about @mentions and new items in #{bucket.name}"
end
end
end
+1 -1
View File
@@ -18,7 +18,7 @@ module TranslationsHelper
def translation_button(translation_key)
tag.div(class: "position-relative", data: { controller: "popover", action: "keydown.esc->popover#close click@document->popover#closeOnClickOutside", popover_orientation_top_class: "popover-orientation-top" }) do
tag.button(type: "button", class: "btn", tabindex: -1, data: { action: "popover#toggle" }) do
concat image_tag("globe.svg", size: 20, role: "presentation")
concat image_tag("globe.svg", class: "icon", role: "presentation")
concat tag.span("Translate", class: "for-screen-reader")
end +
tag.dialog(class: "lanuage-list-menu popover shadow", data: { popover_target: "menu" }) do
+2
View File
@@ -1,4 +1,6 @@
class Access < ApplicationRecord
belongs_to :bucket
belongs_to :user
enum :involvement, %i[ access_only watching everything ].index_by(&:itself)
end
+6 -3
View File
@@ -9,15 +9,18 @@ module Bubble::Watchable
end
def watched_by?(user)
watchers_and_subscribers.include?(user)
watchers_and_subscribers(include_only_watching: true).include?(user)
end
def set_watching(user, watching)
watches.where(user: user).first_or_create.update!(watching: watching)
end
def watchers_and_subscribers
User.where(id: bucket.subscribers.pluck(:id) +
def watchers_and_subscribers(include_only_watching: false)
involvements = include_only_watching ? [ :watching, :everything ] : :everything
subscribers = bucket.users.where(accesses: { involvement: involvements })
User.where(id: subscribers.pluck(:id) +
watches.watching.pluck(:user_id) - watches.not_watching.pluck(:user_id))
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Bucket < ApplicationRecord
include Accessible, Broadcastable, Filterable, Subscribable
include Accessible, Broadcastable, Filterable
belongs_to :account
belongs_to :creator, class_name: "User", default: -> { Current.user }
+5
View File
@@ -20,6 +20,7 @@ module Bucket::Accessible
end
has_many :users, through: :accesses
has_many :access_only_users, -> { merge(Access.access_only) }, through: :accesses, source: :user
scope :all_access, -> { where(all_access: true) }
@@ -27,6 +28,10 @@ module Bucket::Accessible
after_save_commit :grant_access_to_everyone
end
def access_for(user)
accesses.find_by(user: user)
end
private
def grant_access_to_everyone
accesses.grant_to(account.users) if all_access_previously_changed?(to: true)
-22
View File
@@ -1,22 +0,0 @@
module Subscribable
extend ActiveSupport::Concern
TYPES = %w[ Bucket ]
included do
has_many :subscriptions, as: :subscribable, dependent: :destroy
has_many :subscribers, through: :subscriptions, source: :user
end
def subscribe(user)
subscriptions.create_or_find_by!(user: user)
end
def unsubscribe(user)
subscriptions.find_by(user: user)&.destroy!
end
def subscribed_by?(user)
subscriptions.exists?(user: user)
end
end
+1 -1
View File
@@ -27,7 +27,7 @@ class Notifier
end
def recipients
bubble.watchers.without(creator)
bubble.watchers_and_subscribers.without(creator)
end
def resource
+1 -1
View File
@@ -1,6 +1,6 @@
class Notifier::Assigned < Notifier
private
def recipients
event.assignees
event.assignees.excluding(bubble.bucket.access_only_users)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class Notifier::Published < Notifier
private
def recipients
bubble.watchers_and_subscribers.without(creator)
bubble.watchers_and_subscribers(include_only_watching: true).without(creator)
end
end
-5
View File
@@ -1,5 +0,0 @@
class Subscription < ApplicationRecord
belongs_to :user
delegated_type :subscribable, types: Subscribable::TYPES, inverse_of: :subscription
end
-1
View File
@@ -9,7 +9,6 @@ class User < ApplicationRecord
has_many :accesses, dependent: :destroy
has_many :buckets, through: :accesses
has_many :accessible_bubbles, through: :buckets, source: :bubbles
has_many :subscriptions, dependent: :destroy
has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy
@@ -0,0 +1 @@
<%= access_involvement_advance_button(@bucket, Current.user) %>
@@ -1,18 +1,13 @@
<%= turbo_frame_tag dom_id(bucket, :subscription) do %>
<%= form_with url: bucket_subscriptions_path(bucket), method: :put, data: { controller: "form"} do |form| %>
<div class="flex align-center gap-half pad-inline">
<h2 class="txt-medium overflow-ellipsis">
<%= bucket.name %>
</h2>
<%= turbo_frame_tag dom_id(bucket, :involvement) do %>
<div class="flex align-center gap-half pad-inline">
<h2 class="txt-medium overflow-ellipsis">
<%= bucket.name %>
</h2>
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-subtle-dark); --border-style: dashed" aria-hidden="true">
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-subtle-dark); --border-style: dashed" aria-hidden="true">
<label class="switch flex-item-no-shrink">
<%= form.checkbox :subscribed, checked: bucket.subscribed_by?(user), id: dom_id(bucket, :subscribed),
class: "switch__input", include_hidden: false, data: { action: "form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Enable notifications for this bucket</span>
</label>
</div>
<% end %>
<label class="switch flex-item-no-shrink">
<%= access_involvement_advance_button(bucket, user) %>
</label>
</div>
<% end %>
+1
View File
@@ -36,6 +36,7 @@ Rails.application.routes.draw do
scope module: :buckets do
resource :subscriptions
resource :workflow, only: :update
resource :involvement
end
resources :bubbles do
@@ -0,0 +1,7 @@
class AddInvolvementToAccesses < ActiveRecord::Migration[8.1]
def change
change_table :accesses do |t|
t.string :involvement, null: false, default: "watching"
end
end
end
@@ -0,0 +1,14 @@
class DropSubscriptions < ActiveRecord::Migration[8.1]
def change
execute "
update accesses set involvement = 'access_only'
"
execute "
update accesses set involvement = 'watching'
from (select user_id, subscribable_id as bucket_id from subscriptions) as subscriptions
where subscriptions.user_id = accesses.user_id and subscriptions.bucket_id = accesses.bucket_id
"
drop_table :subscriptions
end
end
Generated
+2 -12
View File
@@ -14,6 +14,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_03_094604) do
create_table "accesses", force: :cascade do |t|
t.integer "bucket_id", null: false
t.datetime "created_at", null: false
t.string "involvement", default: "watching", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["bucket_id", "user_id"], name: "index_accesses_on_bucket_id_and_user_id", unique: true
@@ -229,6 +230,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_03_094604) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["bubble_id", "user_id"], name: "index_pins_on_bubble_id_and_user_id", unique: true
t.index ["bubble_id"], name: "index_pins_on_bubble_id"
t.index ["user_id"], name: "index_pins_on_user_id"
end
@@ -261,17 +263,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_03_094604) do
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "subscriptions", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "subscribable_id", null: false
t.string "subscribable_type", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["subscribable_type", "subscribable_id", "user_id"], name: "idx_on_subscribable_type_subscribable_id_user_id_81936d569b", unique: true
t.index ["subscribable_type", "subscribable_id"], name: "index_subscriptions_on_subscribable"
t.index ["user_id"], name: "index_subscriptions_on_user_id"
end
create_table "taggings", force: :cascade do |t|
t.integer "bubble_id", null: false
t.datetime "created_at", null: false
@@ -344,7 +335,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_03_094604) do
add_foreign_key "pops", "bubbles"
add_foreign_key "pops", "users"
add_foreign_key "sessions", "users"
add_foreign_key "subscriptions", "users"
add_foreign_key "taggings", "bubbles"
add_foreign_key "taggings", "tags"
add_foreign_key "users", "accounts"
+31 -83
View File
@@ -70,12 +70,9 @@ columns:
default_function:
collation:
comment:
accounts:
- *5
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: join_code
name: involvement
cast_type: &7 !ruby/object:ActiveModel::Type::String
true: t
false: f
@@ -88,6 +85,19 @@ columns:
limit:
precision:
scale:
'null': false
default: watching
default_function:
collation:
comment:
accounts:
- *5
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: join_code
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
@@ -833,31 +843,6 @@ columns:
collation:
comment:
- *30
subscriptions:
- *5
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: subscribable_id
cast_type: *1
sql_type_metadata: *2
'null': false
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: subscribable_type
cast_type: *7
sql_type_metadata: *8
'null': false
default:
default_function:
collation:
comment:
- *9
- *30
taggings:
- *5
- *25
@@ -982,7 +967,6 @@ primary_keys:
reactions: id
schema_migrations: version
sessions: id
subscriptions: id
taggings: id
tags: id
users: id
@@ -1018,7 +1002,6 @@ data_sources:
reactions: true
schema_migrations: true
sessions: true
subscriptions: true
taggings: true
tags: true
users: true
@@ -1801,6 +1784,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: pins
name: index_pins_on_bubble_id_and_user_id
unique: true
columns:
- bubble_id
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
pops:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: pops
@@ -1885,58 +1885,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
subscriptions:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: subscriptions
name: index_subscriptions_on_user_id
unique: false
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: subscriptions
name: index_subscriptions_on_subscribable
unique: false
columns:
- subscribable_type
- subscribable_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: subscriptions
name: idx_on_subscribable_type_subscribable_id_user_id_81936d569b
unique: true
columns:
- subscribable_type
- subscribable_id
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
taggings:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: taggings
@@ -0,0 +1,18 @@
require "test_helper"
class Buckets::InvolvementsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "update" do
bucket = buckets(:writebook)
bucket.access_for(users(:kevin)).access_only!
assert_changes -> { bucket.access_for(users(:kevin)).involvement }, from: "access_only", to: "watching" do
put bucket_involvement_url(bucket, involvement: "watching")
end
assert_response :success
end
end
@@ -1,25 +0,0 @@
require "test_helper"
class Buckets::SubscriptionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :david
end
test "subscribing to a bucket" do
assert_changes -> { buckets(:writebook).subscribed_by?(users(:david)) }, from: false, to: true do
put bucket_subscriptions_url(buckets(:writebook)), params: { subscribed: "1" }
end
assert_response :success
end
test "unsubscribing from a bucket" do
buckets(:writebook).subscribe(users(:david))
assert_changes -> { buckets(:writebook).subscribed_by?(users(:david)) }, from: true, to: false do
put bucket_subscriptions_url(buckets(:writebook))
end
assert_response :success
end
end
-7
View File
@@ -1,7 +0,0 @@
writebook_kevin:
subscribable: writebook (Bucket)
user: kevin
writebook_jz:
subscribable: writebook (Bucket)
user: jz
+1 -1
View File
@@ -2,7 +2,7 @@ require "test_helper"
class Bubble::CommentableTest < ActiveSupport::TestCase
test "creating a comment on a bubble makes the creator watch the bubble" do
buckets(:writebook).subscriptions.destroy_all
buckets(:writebook).access_for(users(:kevin)).access_only!
assert_not bubbles(:text).watched_by?(users(:kevin))
with_current_user(:kevin) do
+5 -8
View File
@@ -3,7 +3,7 @@ require "test_helper"
class Bubble::WatchableTest < ActiveSupport::TestCase
setup do
Watch.destroy_all
Subscription.destroy_all
Access.all.update!(involvement: :access_only)
end
test "watched_by?" do
@@ -16,9 +16,8 @@ class Bubble::WatchableTest < ActiveSupport::TestCase
assert_not bubbles(:logo).watched_by?(users(:kevin))
end
test "watched_by? when subscribed to the bucket" do
buckets(:writebook).subscribe(users(:kevin))
test "watched_by? when notifications are set on the bucket" do
buckets(:writebook).access_for(users(:kevin)).watching!
assert bubbles(:text).watched_by?(users(:kevin))
bubbles(:logo).set_watching(users(:kevin), false)
@@ -26,16 +25,14 @@ class Bubble::WatchableTest < ActiveSupport::TestCase
end
test "bubbles are initially watched by their creator" do
buckets(:writebook).unsubscribe(users(:kevin))
bubble = buckets(:writebook).bubbles.create!(creator: users(:kevin))
assert bubble.watched_by?(users(:kevin))
end
test "watchers_and_subscribers" do
buckets(:writebook).subscribe(users(:kevin))
buckets(:writebook).subscribe(users(:jz))
buckets(:writebook).access_for(users(:kevin)).watching!
buckets(:writebook).access_for(users(:jz)).everything!
bubbles(:logo).set_watching(users(:kevin), true)
bubbles(:logo).set_watching(users(:jz), false)
+20 -1
View File
@@ -28,6 +28,14 @@ class NotifierTest < ActiveSupport::TestCase
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "does not create a notification for access-only users" do
buckets(: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
@@ -40,9 +48,20 @@ class NotifierTest < ActiveSupport::TestCase
assert_equal bubbles(:logo), Notification.last.resource
end
test "for assignment events only create a notification for the assignee" do
test "assignment events only create a notification for the assignee" do
buckets(:writebook).access_for(users(:jz)).watching!
buckets(: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
buckets(:writebook).access_for(users(:jz)).access_only!
notifications = Notifier.for(events(:logo_assignment_jz)).generate
assert_empty notifications
end
end