Merge branch 'main' into new-filters
* main: (24 commits) Increase filter tests specificity Add some more tests for bubble filters Simplify filter destruction Add derived_params alias Split out `fields` and `params` for filters exists? -> any? Assert params after resource removal Put includes on the same line Comment on the query merger controller Test param sanitization Sanitize params after initialization Unnecessary parens Improve denormalized filters note idempotent_create -> persist Pull out `strip_default_params` Update denormalization comment Unnecessary newline No need to set resources in `to_params` anymore Delegate default_params to class Add tests ...
This commit is contained in:
@@ -1,16 +1,14 @@
|
||||
class BubblesController < ApplicationController
|
||||
include BucketScoped
|
||||
|
||||
skip_before_action :set_bucket, only: :index
|
||||
|
||||
before_action :set_filter, only: :index
|
||||
before_action :set_bubble, only: %i[ show edit update ]
|
||||
before_action :clear_assignees, only: :index
|
||||
before_action :set_view, :set_filter, only: :index
|
||||
|
||||
def index
|
||||
@bubbles = @filter.bubbles
|
||||
end
|
||||
|
||||
def new
|
||||
@bubble = @bucket.bubbles.build
|
||||
@bubbles = @bubbles.mentioning(params[:term]) if params[:term].present?
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -31,6 +29,10 @@ class BubblesController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def set_filter
|
||||
@filter = Current.user.filters.build params.permit(*Filter::KNOWN_PARAMS)
|
||||
end
|
||||
|
||||
def set_bubble
|
||||
@bubble = @bucket.bubbles.find params[:id]
|
||||
end
|
||||
@@ -38,18 +40,4 @@ class BubblesController < ApplicationController
|
||||
def bubble_params
|
||||
params.expect(bubble: [ :title, :color, :due_on, :image, tag_ids: [] ])
|
||||
end
|
||||
|
||||
def clear_assignees
|
||||
params[:assignee_ids] = nil if helpers.unassigned_filter_activated?
|
||||
end
|
||||
|
||||
def set_view
|
||||
@view = @bucket.views.find_by(creator: Current.user, id: params[:view_id]) if params[:view_id]
|
||||
@view ||= @bucket.views.find_by(creator: Current.user, filters: helpers.bubble_filter_params.to_h)
|
||||
params[:view_id] = @view&.id
|
||||
end
|
||||
|
||||
def set_filter
|
||||
@filter = @bucket.bubble_filter_from helpers.view_filter_params
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
class Buckets::ViewsController < ApplicationController
|
||||
include BucketScoped
|
||||
|
||||
before_action :set_view, only: %i[ update destroy ]
|
||||
|
||||
def create
|
||||
@view = @bucket.views.create! filters: filter_params
|
||||
redirect_to bucket_bubbles_path(@bucket, **filter_params.merge(view_id: @view.id)), notice: "✓"
|
||||
end
|
||||
|
||||
def update
|
||||
@view.update! filters: filter_params
|
||||
redirect_to bucket_bubbles_path(@bucket, **filter_params.merge(view_id: @view.id)), notice: "✓"
|
||||
end
|
||||
|
||||
def destroy
|
||||
@view.destroy
|
||||
redirect_to buckets_path
|
||||
end
|
||||
|
||||
private
|
||||
def set_view
|
||||
@view = @bucket.views.find_by creator: Current.user, id: params[:id]
|
||||
end
|
||||
|
||||
def filter_params
|
||||
helpers.bubble_filter_params.to_h
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,8 @@ class BucketsController < ApplicationController
|
||||
before_action :set_bucket, except: %i[ index new create ]
|
||||
|
||||
def index
|
||||
@buckets = (Current.user.buckets.all + Current.user.bucket_views.all).sort_by(&:updated_at).reverse!
|
||||
@filters = Current.user.filters.all
|
||||
@buckets = Current.user.buckets.all
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -11,7 +12,7 @@ class BucketsController < ApplicationController
|
||||
|
||||
def create
|
||||
@bucket = Current.account.buckets.create! bucket_params
|
||||
redirect_to bucket_bubbles_path(@bucket)
|
||||
redirect_to bubbles_path(bucket_ids: [ @bucket ])
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -23,7 +24,7 @@ class BucketsController < ApplicationController
|
||||
@bucket.update! bucket_params
|
||||
@bucket.accesses.revise granted: grantees, revoked: revokees
|
||||
|
||||
redirect_to bucket_bubbles_path(@bucket)
|
||||
redirect_to bubbles_path(bucket_ids: [ @bucket ])
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
class FiltersController < ApplicationController
|
||||
before_action :set_filter, only: :destroy
|
||||
|
||||
def create
|
||||
@filter = Current.user.filters.persist! filter_params
|
||||
redirect_to bubbles_path(@filter.to_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@filter.destroy!
|
||||
redirect_after_destroy
|
||||
end
|
||||
|
||||
private
|
||||
def set_filter
|
||||
@filter = Current.user.filters.find params[:id]
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(*Filter::KNOWN_PARAMS).compact_blank
|
||||
end
|
||||
|
||||
def redirect_after_destroy
|
||||
if request.referer == root_url
|
||||
redirect_to root_path
|
||||
else
|
||||
redirect_to bubbles_path(@filter.to_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,61 +1,27 @@
|
||||
module FiltersHelper
|
||||
def main_filter_text
|
||||
(Bucket::View::ORDERS[params[:order_by]] || Bucket::View::STATUSES[params[:status]] || Bubble.default_order_by).humanize
|
||||
end
|
||||
|
||||
def tag_filter_text(filter)
|
||||
if filter.tags
|
||||
filter.tags.map(&:hashtag).to_choice_sentence
|
||||
def buckets_filter_text(filter)
|
||||
if filter.buckets.present?
|
||||
filter.buckets.map(&:name).to_choice_sentence
|
||||
else
|
||||
"any tag"
|
||||
"all projects"
|
||||
end
|
||||
end
|
||||
|
||||
def assignee_filter_text(filter)
|
||||
if filter.assignees
|
||||
"assigned to #{filter.assignees.pluck(:name).to_choice_sentence}"
|
||||
def assignments_filter_text(filter)
|
||||
if filter.assignees.present?
|
||||
"assigned to #{filter.assignees.map(&:name).to_choice_sentence}"
|
||||
elsif filter.assignments.unassigned?
|
||||
"assigned to no one"
|
||||
else
|
||||
"assigned to anyone"
|
||||
end
|
||||
end
|
||||
|
||||
# `#bubble_filter_params` is memoized to avoid spam in logs about unpermitted params
|
||||
def bubble_filter_params
|
||||
@bubble_filter_params ||= params.permit :order_by, :status, assignee_ids: [], tag_ids: []
|
||||
end
|
||||
|
||||
# `#view_filter_params` is memoized to avoid spam in logs about unpermitted params
|
||||
def view_filter_params
|
||||
@view_filter_params ||= bubble_filter_params.merge params.permit(:term, :view_id)
|
||||
end
|
||||
|
||||
def unassigned_filter_activated?
|
||||
params[:status] == "unassigned"
|
||||
end
|
||||
|
||||
def default_filters?
|
||||
bubble_filter_params.values.all?(&:blank?) || bubble_filter_params.to_h == Bucket::View.default_filters
|
||||
end
|
||||
|
||||
def bubble_filter_form_tag(path, method:, id: nil)
|
||||
form_tag path, method: method, id: id, class: "full-width" do
|
||||
yield if block_given?
|
||||
|
||||
if params[:order_by].present?
|
||||
concat hidden_field_tag(:order_by, params[:order_by])
|
||||
end
|
||||
|
||||
if params[:status].present?
|
||||
concat hidden_field_tag(:status, params[:status])
|
||||
end
|
||||
|
||||
Array(params[:assignee_ids]).each do |assignee_id|
|
||||
concat hidden_field_tag("assignee_ids[]", assignee_id, id: nil)
|
||||
end
|
||||
|
||||
Array(params[:tag_ids]).each do |tag_id|
|
||||
concat hidden_field_tag("tag_ids[]", tag_id, id: nil)
|
||||
end
|
||||
def tags_filter_text(filter)
|
||||
if filter.tags.present?
|
||||
filter.tags.map(&:hashtag).to_choice_sentence
|
||||
else
|
||||
"any tag"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { fallbackDestination: String }
|
||||
|
||||
connect() {
|
||||
if (history.state.turbo?.restorationIndex > 0) {
|
||||
this.element.href = "javascript:history.back()"
|
||||
} else {
|
||||
this.element.href = this.fallbackDestinationValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// FIXME: Can we do this without a controller? https://github.com/basecamp/fizzy/pull/130#discussion_r1833094616
|
||||
export default class extends Controller {
|
||||
merge({ params: { key, value } }) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(key, value)
|
||||
Turbo.visit(url)
|
||||
}
|
||||
}
|
||||
+5
-19
@@ -1,7 +1,7 @@
|
||||
class Bubble < ApplicationRecord
|
||||
include Assignable, Boostable, Colored, Eventable, Messages, Poppable, Searchable, Staged, Taggable
|
||||
|
||||
belongs_to :bucket
|
||||
belongs_to :bucket, touch: true
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
|
||||
has_one_attached :image, dependent: :purge_later
|
||||
@@ -10,6 +10,7 @@ class Bubble < ApplicationRecord
|
||||
|
||||
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
|
||||
scope :chronologically, -> { order created_at: :asc, id: :asc }
|
||||
scope :in_bucket, ->(bucket) { where bucket: bucket }
|
||||
|
||||
# FIXME: Compute activity and comment count at write time so it's easier to query for.
|
||||
scope :left_joins_comments, -> do
|
||||
@@ -22,29 +23,14 @@ class Bubble < ApplicationRecord
|
||||
left_joins_comments.select("bubbles.*, COUNT(comments.id) AS comment_count").group(:id).order("comment_count DESC")
|
||||
end
|
||||
|
||||
# FIXME: `status` implies an enum. Consider a name change.
|
||||
scope :with_status, ->(status) do
|
||||
status = status.presence_in %w[ popped active unassigned ]
|
||||
public_send(status) if status
|
||||
end
|
||||
|
||||
scope :ordered_by, ->(order) do
|
||||
case order
|
||||
scope :indexed_by, ->(index) do
|
||||
case index
|
||||
when "most_active" then ordered_by_activity
|
||||
when "most_discussed" then ordered_by_comments
|
||||
when "most_boosted" then ordered_by_boosts
|
||||
when "newest" then reverse_chronologically
|
||||
when "oldest" then chronologically
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def default_order_by
|
||||
"most_active"
|
||||
end
|
||||
|
||||
def default_status
|
||||
"active"
|
||||
when "popped" then popped
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ module Bubble::Searchable
|
||||
searchable_by :title, using: :bubbles_search_index
|
||||
|
||||
scope :mentioning, ->(query) do
|
||||
if query = query.presence
|
||||
bubbles = search(query).select(:id).to_sql
|
||||
comments = Comment.search(query).select(:id).to_sql
|
||||
left_joins(:messages).where("bubbles.id in (#{bubbles}) or messages.messageable_id in (#{comments})").distinct
|
||||
end
|
||||
bubbles = Bubble.search(query).select(:id).to_sql
|
||||
comments = Comment.search(query).select(:id).to_sql
|
||||
left_joins(:messages).where("bubbles.id in (#{bubbles}) or messages.messageable_id in (#{comments})").distinct
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Bucket < ApplicationRecord
|
||||
include Accessible, Filterable, Views
|
||||
include Accessible, Filterable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
class Bucket::BubbleFilter
|
||||
def initialize(bucket, params = {})
|
||||
@bucket = bucket
|
||||
@status = params["status"]
|
||||
@order_by = params["order_by"]
|
||||
@term = params["term"]
|
||||
@tag_ids = params["tag_ids"]
|
||||
@assignee_ids = params["assignee_ids"]
|
||||
end
|
||||
|
||||
def bubbles
|
||||
@bubbles ||= begin
|
||||
result = bucket.bubbles
|
||||
result = result.ordered_by(order_by || Bubble.default_order_by)
|
||||
result = result.with_status(status || Bubble.default_status)
|
||||
result = result.tagged_with(tags) if tags
|
||||
result = result.assigned_to(assignees) if assignees
|
||||
result = result.mentioning(term) if term
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def tags
|
||||
@tags ||= account.tags.where(id: tag_ids) if tag_ids
|
||||
end
|
||||
|
||||
def assignees
|
||||
@assignees ||= account.users.where(id: assignee_ids) if assignee_ids
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :bucket, :status, :order_by, :term, :tag_ids, :assignee_ids
|
||||
delegate :account, to: :bucket, private: true
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
module Bucket::Filterable
|
||||
def bubble_filter_from(params = {})
|
||||
Bucket::BubbleFilter.new self, params
|
||||
end
|
||||
end
|
||||
@@ -1,27 +0,0 @@
|
||||
class Bucket::View < ApplicationRecord
|
||||
include Assignees, OrderBy, Status, Summarized, Tags
|
||||
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
belongs_to :bucket
|
||||
|
||||
has_one :account, through: :creator
|
||||
|
||||
validate :must_not_be_the_default_view
|
||||
|
||||
class << self
|
||||
def default_filters
|
||||
{ "order_by" => "most_active" }
|
||||
end
|
||||
end
|
||||
|
||||
def bubbles
|
||||
@bubbles ||= bucket.bubble_filter_from(filters).bubbles
|
||||
end
|
||||
|
||||
private
|
||||
def must_not_be_the_default_view
|
||||
if filters.values.all?(&:blank?) || filters == self.class.default_filters
|
||||
errors.add :base, "must be different than the default view"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
module Bucket::View::Assignees
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :filters, :assignee_ids
|
||||
end
|
||||
|
||||
private
|
||||
def assignees
|
||||
@assignees ||= account.users.where id: assignee_ids
|
||||
end
|
||||
end
|
||||
@@ -1,14 +0,0 @@
|
||||
module Bucket::View::OrderBy
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :filters, :order_by
|
||||
end
|
||||
|
||||
private
|
||||
ORDERS = {
|
||||
"most_discussed" => "most comments",
|
||||
"most_boosted" => "most boosts",
|
||||
"newest" => "newest",
|
||||
"oldest" => "oldest" }
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
module Bucket::View::Status
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :filters, :status
|
||||
end
|
||||
|
||||
private
|
||||
STATUSES = %w[ unassigned popped ].index_by(&:itself)
|
||||
end
|
||||
@@ -1,24 +0,0 @@
|
||||
module Bucket::View::Summarized
|
||||
def summary
|
||||
[ order_by_summary, status_summary, tag_summary, assignee_summary ].compact.to_sentence.upcase_first
|
||||
end
|
||||
|
||||
private
|
||||
delegate :to_sentence, to: "ApplicationController.helpers", private: true
|
||||
|
||||
def order_by_summary
|
||||
Bucket::View::ORDERS.fetch order_by, nil
|
||||
end
|
||||
|
||||
def status_summary
|
||||
Bucket::View::STATUSES.fetch status, nil
|
||||
end
|
||||
|
||||
def assignee_summary
|
||||
"assigned to <mark>#{assignees.pluck(:name).to_choice_sentence}</mark>" if assignees.any?
|
||||
end
|
||||
|
||||
def tag_summary
|
||||
"tagged <mark>#{tags.map(&:hashtag).to_choice_sentence}</mark>" if tags.any?
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
module Bucket::View::Tags
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :filters, :tag_ids
|
||||
end
|
||||
|
||||
private
|
||||
def tags
|
||||
@tags ||= account.tags.where id: tag_ids
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
module Bucket::Views
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :views, dependent: :delete_all
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module Filterable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_and_belongs_to_many :filters
|
||||
|
||||
after_update { filters.touch_all }
|
||||
before_destroy :remove_from_filters
|
||||
end
|
||||
|
||||
private
|
||||
def remove_from_filters
|
||||
filters.each { |filter| filter.resource_removed self }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Filter < ApplicationRecord
|
||||
include Fields, Params, Resources, Summarized
|
||||
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
has_one :account, through: :creator
|
||||
|
||||
class << self
|
||||
def persist!(attrs)
|
||||
filter = new(attrs)
|
||||
filter.save!
|
||||
filter
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
find_by!(params: filter.params).tap(&:touch)
|
||||
end
|
||||
end
|
||||
|
||||
def bubbles
|
||||
@bubbles ||= begin
|
||||
result = creator.accessible_bubbles.indexed_by(indexed_by)
|
||||
result = result.active unless indexed_by.popped?
|
||||
result = result.unassigned if assignments.unassigned?
|
||||
result = result.assigned_to(assignees.ids) if assignees.present?
|
||||
result = result.in_bucket(buckets.ids) if buckets.present?
|
||||
result = result.tagged_with(tags.ids) if tags.present?
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def cacheable?
|
||||
buckets.exists?
|
||||
end
|
||||
|
||||
def cache_key
|
||||
ActiveSupport::Cache.expand_cache_key buckets.cache_key_with_version, super
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module Filter::Fields
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
INDEXES = %w[ most_active most_discussed most_boosted newest oldest popped ]
|
||||
|
||||
class_methods do
|
||||
def default_fields
|
||||
{ "indexed_by" => "most_active" }
|
||||
end
|
||||
end
|
||||
|
||||
def assignments=(value)
|
||||
fields["assignments"] = value
|
||||
end
|
||||
|
||||
def assignments
|
||||
fields["assignments"].to_s.inquiry
|
||||
end
|
||||
|
||||
def indexed_by=(value)
|
||||
fields["indexed_by"] = value
|
||||
end
|
||||
|
||||
def indexed_by
|
||||
(fields["indexed_by"] || default_fields["indexed_by"]).inquiry
|
||||
end
|
||||
|
||||
private
|
||||
delegate :default_fields, to: :class, private: true
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
module Filter::Params
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
KNOWN_PARAMS = [ :indexed_by, :assignments, bucket_ids: [], assignee_ids: [], tag_ids: [] ]
|
||||
|
||||
included do
|
||||
after_initialize :derive_params
|
||||
end
|
||||
|
||||
def to_params
|
||||
ActionController::Parameters.new(params).permit(*KNOWN_PARAMS).tap do |params|
|
||||
params[:filter_id] = id if persisted?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# `derive_params` stores a denormalized version of the filter in `params` to
|
||||
# 1) Enforce uniqueness via db constraints
|
||||
# 2) Look up identical filters by a single column
|
||||
# 3) Easily turn all filter params into a query string
|
||||
def derive_params
|
||||
derive_params_from_resource_ids
|
||||
derive_params_from_fields
|
||||
params.compact_blank!
|
||||
end
|
||||
alias_method :derived_params, :derive_params
|
||||
|
||||
def derive_params_from_resource_ids
|
||||
params["tag_ids"] = tags.ids
|
||||
params["bucket_ids"] = buckets.ids
|
||||
params["assignee_ids"] = assignees.ids
|
||||
end
|
||||
|
||||
def derive_params_from_fields
|
||||
self.params.merge! fields.reject { |k, v| default_fields[k] == v }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module Filter::Resources
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_and_belongs_to_many :tags
|
||||
has_and_belongs_to_many :buckets
|
||||
has_and_belongs_to_many :assignees, class_name: "User", join_table: "assignees_filters", association_foreign_key: "assignee_id"
|
||||
end
|
||||
|
||||
def resource_removed(resource)
|
||||
kind = resource.class.model_name.plural
|
||||
send "#{kind}=", send(kind).without(resource)
|
||||
derived_params.blank? ? destroy! : save!
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
module Filter::Summarized
|
||||
def summary
|
||||
[ index_summary, tag_summary, assignee_summary ].compact.to_sentence + " #{bucket_summary}"
|
||||
end
|
||||
|
||||
def plain_summary
|
||||
summary.remove(/<\/?mark>/)
|
||||
end
|
||||
|
||||
private
|
||||
def index_summary
|
||||
"<mark>#{indexed_by.humanize}</mark>"
|
||||
end
|
||||
|
||||
def tag_summary
|
||||
if tags.any?
|
||||
"tagged <mark>#{tags.map(&:hashtag).to_choice_sentence}</mark>"
|
||||
end
|
||||
end
|
||||
|
||||
def assignee_summary
|
||||
if assignees.any?
|
||||
"assigned to <mark>#{assignees.pluck(:name).to_choice_sentence}</mark>"
|
||||
elsif assignments.unassigned?
|
||||
"assigned to no one"
|
||||
end
|
||||
end
|
||||
|
||||
def bucket_summary
|
||||
if buckets.any?
|
||||
"in <mark>#{buckets.pluck(:name).to_choice_sentence}</mark>"
|
||||
else
|
||||
"in <mark>all projects</mark>"
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
class Pop < ApplicationRecord
|
||||
belongs_to :bubble
|
||||
belongs_to :bubble, touch: true
|
||||
belongs_to :user, optional: true
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Tag < ApplicationRecord
|
||||
include Filterable
|
||||
|
||||
belongs_to :account
|
||||
|
||||
has_many :taggings, dependent: :destroy
|
||||
|
||||
+4
-3
@@ -1,6 +1,4 @@
|
||||
class User < ApplicationRecord
|
||||
include Viewer
|
||||
|
||||
belongs_to :account
|
||||
|
||||
has_many :sessions, dependent: :destroy
|
||||
@@ -8,7 +6,10 @@ class User < ApplicationRecord
|
||||
|
||||
has_many :accesses, dependent: :destroy
|
||||
has_many :buckets, through: :accesses
|
||||
has_many :bubbles, through: :buckets
|
||||
has_many :accessible_bubbles, through: :buckets, source: :bubbles
|
||||
|
||||
has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy
|
||||
|
||||
has_many :pops, dependent: :nullify
|
||||
|
||||
has_many :assignments, foreign_key: :assignee_id, dependent: :destroy
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
module User::Viewer
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :bucket_views, class_name: "Bucket::View", foreign_key: :creator_id, dependent: :destroy
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
<% bubble.tags.each do |tag| %>
|
||||
<%= link_to "##{tag.title}", bucket_bubbles_path(bubble.bucket, tag_id: tag), class: "bubble__bubble bubble__meta bubble__tag" %>
|
||||
<%= link_to tag.hashtag, bubbles_path(bucket_id: bubble.bucket, tag_ids: tag), class: "bubble__bubble bubble__meta bubble__tag" %>
|
||||
<% end %>
|
||||
|
||||
<% if bubble.tags.size < 3 %>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<div class="filter align-center gap-half" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn btn--plain filter__button" data-action="click->dialog#toggle" data-dialog-modal-value="true">
|
||||
<%= assignee_filter_text filter %>
|
||||
</button>
|
||||
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog">
|
||||
<menu class="filter__menu unpad margin-none">
|
||||
<% bucket.users.active.order(:name).each do |user| %>
|
||||
<li><%= link_to user.name, bucket_bubbles_path(bucket, view_filter_params.merge(assignee_ids: [ user.id ])) %></li>
|
||||
<% end %>
|
||||
</menu>
|
||||
</dialog>
|
||||
|
||||
<% if filter.assignees %>
|
||||
<%= link_to bucket_bubbles_path(bucket, view_filter_params.without(:assignee_ids)), class: "btn", style: "font-size: 0.4em;" do %>
|
||||
<%= image_tag "remove.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Clear</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="filter align-center gap-half" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn btn--plain filter__button" data-action="click->dialog#toggle" data-dialog-modal-value="true">
|
||||
<%= assignments_filter_text filter %>
|
||||
</button>
|
||||
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog" data-turbo-temporary>
|
||||
<menu class="filter__menu unpad margin-none">
|
||||
<li><%= link_to "No one", bubbles_path(filter.to_params.merge(assignments: :unassigned, assignee_ids: [])), class: "filter__button" %></li>
|
||||
|
||||
<% Current.account.users.active.order(:name).each do |user| %>
|
||||
<li><%= link_to user.name, bubbles_path(filter.to_params.merge(assignments: nil, assignee_ids: [ user.id ])) %></li>
|
||||
<% end %>
|
||||
</menu>
|
||||
</dialog>
|
||||
|
||||
<% if [ filter.assignees, filter.assignments ].any?(&:present?) %>
|
||||
<%= link_to bubbles_path(filter.to_params.without(:assignments, :assignee_ids)), class: "btn", style: "font-size: 0.4em;" do %>
|
||||
<%= image_tag "remove.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Clear</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<% if params[:filter_id] %>
|
||||
<%= button_to filter_path(params[:filter_id]), method: :delete, class: "btn txt-small borderless" do %>
|
||||
<%= image_tag "bookmark.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Delete this filter</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= form_with url: filters_path, method: :post do %>
|
||||
<%= hidden_field_tag :indexed_by, params[:indexed_by] if params[:indexed_by].present? %>
|
||||
<%= hidden_field_tag :assignments, params[:assignments] if params[:assignments].present? %>
|
||||
|
||||
<% if values = params[:bucket_ids].presence %>
|
||||
<% values.each do |value| %>
|
||||
<%= hidden_field_tag "bucket_ids[]", value, id: nil %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if values = params[:tag_ids].presence %>
|
||||
<% values.each do |value| %>
|
||||
<%= hidden_field_tag "tag_ids[]", value, id: nil %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if values = params[:assignee_ids].presence %>
|
||||
<% values.each do |value| %>
|
||||
<%= hidden_field_tag "assignee_ids[]", value, id: nil %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= button_tag type: :submit, class: "btn txt-small borderless" do %>
|
||||
<%= image_tag "bookmark-outline.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Save this filter</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,19 +0,0 @@
|
||||
<% view ||= Bucket::View.new %>
|
||||
|
||||
<%= bubble_filter_form_tag bucket_views_path(bucket), method: :post, id: dom_id(view, :new_form) %>
|
||||
|
||||
<% if view.persisted? %>
|
||||
<%= bubble_filter_form_tag bucket_view_path(bucket, view), method: :put, id: dom_id(view, :edit_form) %>
|
||||
<% end %>
|
||||
|
||||
<%= button_tag type: :submit, form: dom_id(view, :new_form), class: "btn", style: "font-size: 0.4em;" do %>
|
||||
<%= image_tag "add.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="txt-small">New</span>
|
||||
<% end %>
|
||||
|
||||
<% if view.persisted? %>
|
||||
<%= button_tag type: :submit, form: dom_id(view, :edit_form), class: "btn", style: "font-size: 0.4em;" do %>
|
||||
<%= image_tag "pencil.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="txt-small">Update</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="filter align-center gap-half" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn btn--plain filter__button" data-action="click->dialog#toggle" data-dialog-modal-value="true">
|
||||
<%= buckets_filter_text filter %>
|
||||
</button>
|
||||
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog" data-turbo-temporary>
|
||||
<menu class="filter__menu unpad margin-none">
|
||||
<li><%= link_to "all projects", bubbles_path(filter.to_params.merge(bucket_ids: nil)), class: "filter__button" %></li>
|
||||
|
||||
<% Current.user.buckets.each do |bucket| %>
|
||||
<li><%= link_to bucket.name, bubbles_path(filter.to_params.merge(bucket_ids: [ bucket.id ])), class: "filter__button" %></li>
|
||||
<% end %>
|
||||
</menu>
|
||||
</dialog>
|
||||
</div>
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
<div class="filter align-center gap-half" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn btn--plain filter__button" data-action="click->dialog#toggle" data-dialog-modal-value="true">
|
||||
<%= bucket.name %>
|
||||
<%= filter.indexed_by.humanize %>
|
||||
</button>
|
||||
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog">
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog" data-turbo-temporary>
|
||||
<menu class="filter__menu unpad margin-none">
|
||||
<% Current.user.buckets.order(:name).each do |bucket| %>
|
||||
<li><%= link_to bucket.name, bucket_bubbles_path(bucket, view_filter_params), class: "filter__button" %></li>
|
||||
<% Filter::INDEXES.each do |index| %>
|
||||
<li><%= link_to index.humanize, bubbles_path(filter.to_params.merge(indexed_by: index)), class: "filter__button" %></li>
|
||||
<% end %>
|
||||
</menu>
|
||||
</dialog>
|
||||
@@ -1,17 +0,0 @@
|
||||
<div class="filter align-center gap-half" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn btn--plain filter__button" data-action="click->dialog#toggle" data-dialog-modal-value="true">
|
||||
<%= main_filter_text %>
|
||||
</button>
|
||||
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog">
|
||||
<menu class="filter__menu unpad margin-none">
|
||||
<% Bucket::View::ORDERS.each do |key, value| %>
|
||||
<li><%= link_to value.humanize, bucket_bubbles_path(bucket, view_filter_params.merge(order_by: key, status: nil)), class: "filter__button" %></li>
|
||||
<% end %>
|
||||
|
||||
<% Bucket::View::STATUSES.each do |key, value| %>
|
||||
<li><%= link_to value.humanize, bucket_bubbles_path(bucket, view_filter_params.merge(order_by: nil, status: key)), class: "filter__button" %></li>
|
||||
<% end %>
|
||||
</menu>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -1,18 +1,18 @@
|
||||
<div class="filter align-center gap-half" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn btn--plain filter__button" data-action="click->dialog#toggle" data-dialog-modal-value="true">
|
||||
<%= tag_filter_text filter %>
|
||||
<%= tags_filter_text filter %>
|
||||
</button>
|
||||
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog">
|
||||
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog" data-turbo-temporary>
|
||||
<menu class="filter__menu unpad margin-none">
|
||||
<% bucket.tags.order(:title).each do |tag| %>
|
||||
<li><%= link_to tag.title, bucket_bubbles_path(bucket, view_filter_params.merge(tag_ids: [ tag.id ])) %></li>
|
||||
<% Current.account.tags.order(:title).each do |tag| %>
|
||||
<li><%= link_to tag.title, bubbles_path(filter.to_params.merge(tag_ids: [ tag.id ])) %></li>
|
||||
<% end %>
|
||||
</menu>
|
||||
</dialog>
|
||||
|
||||
<% if filter.tags %>
|
||||
<%= link_to bucket_bubbles_path(bucket, view_filter_params.without(:tag_ids)), class: "btn", style: "font-size: 0.4em;" do %>
|
||||
<% if filter.tags.present? %>
|
||||
<%= link_to bubbles_path(filter.to_params.without(:tag_ids)), class: "btn", style: "font-size: 0.4em;" do %>
|
||||
<%= image_tag "remove.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Clear</span>
|
||||
<% end %>
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
<% @page_title = @bucket.name %>
|
||||
<% @page_title = @filter.plain_summary %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<nav>
|
||||
<%= link_to buckets_path, class: "btn btn--plain flex-item-justify-start" do %>
|
||||
<%= link_to root_path, class: "btn btn--plain flex-item-justify-start" do %>
|
||||
<%= image_tag "bubbles.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">All Buckets</span>
|
||||
<span class="for-screen-reader">All Projects</span>
|
||||
<% end %>
|
||||
|
||||
<header class="txt-align-center">
|
||||
<%= render "bubbles/filters", bucket: @bucket, view: @view, filter: @filter %>
|
||||
<%= render "bubbles/filters", filter: @filter %>
|
||||
</header>
|
||||
|
||||
<%= button_to bucket_bubbles_path(@bucket), method: :post, class: "btn btn--plain", form_class: "flex-item-justify-end" do %>
|
||||
<%= image_tag "bubble-add.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Create a new bubble</span>
|
||||
<% if @filter.buckets.any? %>
|
||||
<%= button_to bucket_bubbles_path(@filter.buckets.first), method: :post, class: "btn btn--plain", form_class: "flex-item-justify-end" do %>
|
||||
<%= image_tag "bubble-add.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Create a new bubble</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="flex-item-justify-end" style="height: var(--btn-size); width: var(--btn-size);">
|
||||
<%# FIXME: This is a placeholder to keep the same layout without a "new bubble" button %>
|
||||
</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<section class="windshield flex-inline flex-wrap gap justify-center align-end" style="view-transition-name: windshield_<%= @bucket.id %>">
|
||||
<section class="windshield flex-inline flex-wrap gap justify-center align-end" style="view-transition-name: windshield_<%= params[:filter_id] %>">
|
||||
<% if @bubbles.any? %>
|
||||
<%= render partial: "bubbles/bubble", collection: @bubbles.limit(10), cached: true %>
|
||||
<% else %>
|
||||
@@ -28,12 +34,12 @@
|
||||
|
||||
<section class="bubbles-list unpad-inline center margin-block-start">
|
||||
<header class="flex-inline align-center gap txt-small margin-block-end-half center">
|
||||
<%= bubble_filter_form_tag bucket_bubbles_path(@bucket), method: :get do %>
|
||||
<%= form_tag bubbles_path(@filter.to_params), method: :get do %>
|
||||
<%= search_field_tag :term, params[:term], class: "input center flex-inline", placeholder: "Type to filter…" %>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<ul class="unpad margin-none flex flex-column txt-align-start center">
|
||||
<%= render partial: "bubbles/list/bubble", collection: @bubbles %>
|
||||
<%= render partial: "bubbles/list/bubble", collection: @bubbles, cached: true %>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
|
||||
<small class="flex align-center gap flex-item-no-shrink">
|
||||
<% bubble.tags.each do |tag| %>
|
||||
<%# FIXME: This is not going to work, this partial must be cacheable, so it can't mutate a changeable view filter live. Need a JS solution. %>
|
||||
<%= link_to "##{tag.title}", bucket_bubbles_path(bubble.bucket, view_filter_params.merge(tag_ids: tag.id)), style: "color: #{bubble.color}" %>
|
||||
<%= button_tag tag.hashtag, type: :button,
|
||||
class: "btn btn--plain", style: "color: #{bubble.color}", data: {
|
||||
controller: "query-merger", action: "query-merger#merge",
|
||||
query_merger_key_param: "tag_ids", query_merger_value_param: tag.id } %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.time bubble.created_at, class: "txt-nowrap flex-item-justify-end" do %>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<% content_for :header do %>
|
||||
<nav>
|
||||
<%= link_to bucket_bubbles_path(@bubble.bucket), class: "btn flex-item-justify-start" do %>
|
||||
<%= link_to "#", class: "btn flex-item-justify-start", data: { controller: "back-navigation", back_navigation_fallback_destination_value: bubbles_path(bucket_id: @bubble.bucket) } do %>
|
||||
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">All Bubbles</span>
|
||||
<span class="for-screen-reader">Back</span>
|
||||
<% end %>
|
||||
|
||||
<section class="flex flex-column align-end gap">
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<li class="bucket flex flex-column txt-align-center max-width position-relative">
|
||||
<%= 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.active.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" />
|
||||
<path d="m391.65 879.47c-110.52-15.95-212.21-91.86-255.92-191.23-66.78-143.65-41.62-347.61 48.08-481.17 368.33-516.3 1252.97 520.2 451.03 660.78-44.07 8.84-88.98 13.49-133.01 15.68-36.69 2-73.37 1.91-109.99-4.03z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<% cache bucket do %>
|
||||
<li class="bucket flex flex-column txt-align-center max-width position-relative">
|
||||
<%= link_to bubbles_path(bucket_ids: [ 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.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" />
|
||||
<path d="m391.65 879.47c-110.52-15.95-212.21-91.86-255.92-191.23-66.78-143.65-41.62-347.61 48.08-481.17 368.33-516.3 1252.97 520.2 451.03 660.78-44.07 8.84-88.98 13.49-133.01 15.68-36.69 2-73.37 1.91-109.99-4.03z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex align-center justify-center flex-column flex-wrap center gap-half">
|
||||
<%= link_to bubbles_path(bucket_ids: [ bucket ]), class: "txt-ink flex flex-column" do %>
|
||||
<strong class="txt-x-large">In <%= bucket.name %></strong>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex align-center justify-center flex-column gap-half center">
|
||||
<strong><%= link_to bucket.name, bucket_bubbles_path(bucket), class: "txt-x-large txt-ink" %></strong>
|
||||
|
||||
<%= link_to edit_bucket_url(bucket), class: "btn" do %>
|
||||
<%= image_tag "settings.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Bucket settings</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<% path = bucket_bubbles_path(view.bucket, **view.filters, view_id: view.id) %>
|
||||
|
||||
<li class="bucket flex flex-column txt-align-center max-width position-relative">
|
||||
<%= link_to path, 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_<%= view.bucket.id %>">
|
||||
<% view.bubbles.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" />
|
||||
<path d="m391.65 879.47c-110.52-15.95-212.21-91.86-255.92-191.23-66.78-143.65-41.62-347.61 48.08-481.17 368.33-516.3 1252.97 520.2 451.03 660.78-44.07 8.84-88.98 13.49-133.01 15.68-36.69 2-73.37 1.91-109.99-4.03z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex align-center justify-center flex-column flex-wrap center gap-half">
|
||||
<%= link_to path, class: "txt-ink flex flex-column" do %>
|
||||
<strong class="txt-x-large"><%= view.summary.html_safe %> in <mark><%= view.bucket.name %></mark></strong>
|
||||
<% end %>
|
||||
|
||||
<%= button_tag type: :submit, form: dom_id(view, :delete_form), class: "btn btn--negative", data: { turbo_confirm: "Are you sure you want to delete this filter?" } do %>
|
||||
<%= image_tag "minus.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Delete</span>
|
||||
<% end %>
|
||||
|
||||
<%= bubble_filter_form_tag bucket_view_path(view.bucket, view), method: :delete, id: dom_id(view, :delete_form) %>
|
||||
</div>
|
||||
</li>
|
||||
@@ -37,13 +37,13 @@
|
||||
<% end %>
|
||||
|
||||
<div data-filter-target="list">
|
||||
<%= render partial: "buckets/user_toggle", collection: @selected_users, as: :user, locals: { bucket: @bucket, selected: true } %>
|
||||
<%= render partial: "buckets/user_toggle", collection: @selected_users, as: :user, locals: { selected: true } %>
|
||||
|
||||
<% if @selected_users.any? && @unselected_users.any? %>
|
||||
<hr class="separator full-width" style="--border-style: solid">
|
||||
<% end %>
|
||||
|
||||
<%= render partial: "buckets/user_toggle", collection: @unselected_users, as: :user, locals: { bucket: @bucket, selected: false } %>
|
||||
<%= render partial: "buckets/user_toggle", collection: @unselected_users, as: :user, locals: { selected: false } %>
|
||||
</div>
|
||||
</menu>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% @page_title = "Buckets" %>
|
||||
<% @page_title = "Projects" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<nav>
|
||||
@@ -9,17 +9,12 @@
|
||||
|
||||
<%= link_to new_bucket_path, class: "btn btn--plain flex-item-justify-end", style: "view-transition-name: new-bucket" do %>
|
||||
<%= image_tag "bubbles-add.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Add a new bucket</span>
|
||||
<span class="for-screen-reader">Add a new project</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<menu class="buckets margin-none unpad align-center justify-center flex flex-wrap gap">
|
||||
<% @buckets.each do |bucket| %>
|
||||
<% if bucket.is_a? Bucket %>
|
||||
<%= render partial: "buckets/bucket", locals: { bucket: bucket } %>
|
||||
<% elsif bucket.is_a? Bucket::View %>
|
||||
<%= render partial: "buckets/view", locals: { view: bucket } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= render @filters %>
|
||||
<%= render @buckets, cached: true %>
|
||||
</menu>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<% cache_if filter.cacheable?, filter do %>
|
||||
<li class="bucket flex flex-column txt-align-center max-width position-relative">
|
||||
<%= link_to bubbles_path(**filter.to_params), 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_<%= filter.id %>">
|
||||
<% filter.bubbles.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" />
|
||||
<path d="m391.65 879.47c-110.52-15.95-212.21-91.86-255.92-191.23-66.78-143.65-41.62-347.61 48.08-481.17 368.33-516.3 1252.97 520.2 451.03 660.78-44.07 8.84-88.98 13.49-133.01 15.68-36.69 2-73.37 1.91-109.99-4.03z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex align-center justify-center flex-column flex-wrap center gap-half">
|
||||
<%= link_to bubbles_path(**filter.to_params), class: "txt-ink flex flex-column" do %>
|
||||
<strong class="txt-x-large"><%= filter.summary.html_safe %></strong>
|
||||
<% end %>
|
||||
<%= button_to filter_path(filter), method: :delete, class: "btn btn--negative", data: { turbo_confirm: "Are you sure you want to delete this filter?" } do %>
|
||||
<%= image_tag "minus.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Delete</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
+3
-4
@@ -12,6 +12,8 @@ Rails.application.routes.draw do
|
||||
route_for :bucket_bubble, bubble.bucket, bubble, options
|
||||
end
|
||||
|
||||
resources :bubbles
|
||||
|
||||
resources :buckets do
|
||||
resources :bubbles do
|
||||
resources :assignments
|
||||
@@ -28,12 +30,9 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
resources :tags, only: :index
|
||||
|
||||
scope module: :buckets do
|
||||
resources :views
|
||||
end
|
||||
end
|
||||
|
||||
resources :filters
|
||||
resource :first_run
|
||||
resource :session
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class RenameBucketViewsToFilters < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
rename_table :bucket_views, :filters
|
||||
|
||||
remove_index :filters, %i[ bucket_id creator_id filters ], unique: true
|
||||
remove_index :filters, :creator_id
|
||||
|
||||
remove_reference :filters, :bucket
|
||||
|
||||
rename_column :filters, :filters, :params
|
||||
|
||||
add_column :filters, :fields, :jsonb, null: false, default: {}
|
||||
|
||||
add_index :filters, %i[ creator_id params ], unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
class CreateFilterJoinTables < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_join_table :filters, :tags do |t|
|
||||
t.index :filter_id
|
||||
t.index :tag_id
|
||||
end
|
||||
|
||||
create_join_table :filters, :buckets do |t|
|
||||
t.index :filter_id
|
||||
t.index :bucket_id
|
||||
end
|
||||
|
||||
create_join_table :filters, :assignees do |t|
|
||||
t.index :filter_id
|
||||
t.index :assignee_id
|
||||
end
|
||||
end
|
||||
end
|
||||
Generated
+31
-11
@@ -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_28_183212) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2024_11_05_224305) do
|
||||
create_table "accesses", force: :cascade do |t|
|
||||
t.integer "bucket_id", null: false
|
||||
t.integer "user_id", null: false
|
||||
@@ -58,6 +58,13 @@ ActiveRecord::Schema[8.0].define(version: 2024_10_28_183212) do
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "assignees_filters", id: false, force: :cascade do |t|
|
||||
t.integer "filter_id", null: false
|
||||
t.integer "assignee_id", null: false
|
||||
t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id"
|
||||
t.index ["filter_id"], name: "index_assignees_filters_on_filter_id"
|
||||
end
|
||||
|
||||
create_table "assignments", force: :cascade do |t|
|
||||
t.integer "assignee_id", null: false
|
||||
t.integer "bubble_id", null: false
|
||||
@@ -82,16 +89,6 @@ ActiveRecord::Schema[8.0].define(version: 2024_10_28_183212) do
|
||||
t.index ["stage_id"], name: "index_bubbles_on_stage_id"
|
||||
end
|
||||
|
||||
create_table "bucket_views", force: :cascade do |t|
|
||||
t.integer "creator_id", null: false
|
||||
t.integer "bucket_id", null: false
|
||||
t.json "filters", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["bucket_id", "creator_id", "filters"], name: "index_bucket_views_on_bucket_id_and_creator_id_and_filters", unique: true
|
||||
t.index ["creator_id"], name: "index_bucket_views_on_creator_id"
|
||||
end
|
||||
|
||||
create_table "buckets", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.integer "creator_id", null: false
|
||||
@@ -102,6 +99,13 @@ ActiveRecord::Schema[8.0].define(version: 2024_10_28_183212) do
|
||||
t.index ["creator_id"], name: "index_buckets_on_creator_id"
|
||||
end
|
||||
|
||||
create_table "buckets_filters", id: false, force: :cascade do |t|
|
||||
t.integer "filter_id", null: false
|
||||
t.integer "bucket_id", null: false
|
||||
t.index ["bucket_id"], name: "index_buckets_filters_on_bucket_id"
|
||||
t.index ["filter_id"], name: "index_buckets_filters_on_filter_id"
|
||||
end
|
||||
|
||||
create_table "comments", force: :cascade do |t|
|
||||
t.text "body", null: false
|
||||
t.integer "creator_id", null: false
|
||||
@@ -125,6 +129,22 @@ ActiveRecord::Schema[8.0].define(version: 2024_10_28_183212) do
|
||||
t.index ["summary_id", "action"], name: "index_events_on_summary_id_and_action"
|
||||
end
|
||||
|
||||
create_table "filters", force: :cascade do |t|
|
||||
t.integer "creator_id", null: false
|
||||
t.json "params", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.json "fields", default: {}, null: false
|
||||
t.index ["creator_id", "params"], name: "index_filters_on_creator_id_and_params", unique: true
|
||||
end
|
||||
|
||||
create_table "filters_tags", id: false, force: :cascade do |t|
|
||||
t.integer "filter_id", null: false
|
||||
t.integer "tag_id", null: false
|
||||
t.index ["filter_id"], name: "index_filters_tags_on_filter_id"
|
||||
t.index ["tag_id"], name: "index_filters_tags_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "messages", force: :cascade do |t|
|
||||
t.integer "bubble_id", null: false
|
||||
t.string "messageable_type", null: false
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
require "test_helper"
|
||||
|
||||
class BubblesControllerTest < ActionDispatch::IntegrationTest
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
end
|
||||
|
||||
test "index" do
|
||||
get bubbles_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "filtered index" do
|
||||
get bubbles_url(filters(:jz_assignments).to_params.merge(term: "haggis"))
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_difference "Bubble.count", 1 do
|
||||
post bucket_bubbles_url(buckets(:writebook))
|
||||
end
|
||||
assert_redirected_to bucket_bubble_url(buckets(:writebook), Bubble.last)
|
||||
end
|
||||
|
||||
test "show" do
|
||||
get bucket_bubble_url(buckets(:writebook), bubbles(:logo))
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "edit" do
|
||||
get edit_bucket_bubble_url(buckets(:writebook), bubbles(:logo))
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "update" do
|
||||
patch bucket_bubble_url(buckets(:writebook), bubbles(:logo)), params: {
|
||||
bubble: {
|
||||
title: "Logo needs to change",
|
||||
color: "#000000",
|
||||
due_on: 1.week.from_now,
|
||||
image: fixture_file_upload("moon.jpg", "image/jpeg"),
|
||||
tag_ids: [ tags(:mobile).id ] } }
|
||||
assert_redirected_to bucket_bubble_url(buckets(:writebook), bubbles(:logo))
|
||||
|
||||
bubble = bubbles(:logo).reload
|
||||
assert_equal "Logo needs to change", bubble.title
|
||||
assert_equal "#000000", bubble.color
|
||||
assert_equal 1.week.from_now.to_date, bubble.due_on
|
||||
assert_equal "moon.jpg", bubble.image.filename.to_s
|
||||
assert_equal [ tags(:mobile) ], bubble.tags
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ class BucketsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
bucket = Bucket.last
|
||||
assert_redirected_to bucket_bubbles_url(bucket)
|
||||
assert_redirected_to bubbles_path(bucket_ids: [ bucket ])
|
||||
assert_includes bucket.users, users(:kevin)
|
||||
assert_equal "Remodel Punch List", bucket.name
|
||||
end
|
||||
@@ -34,7 +34,7 @@ class BucketsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "update" do
|
||||
patch bucket_url(buckets(:writebook)), params: { bucket: { name: "Writebook bugs" }, user_ids: users(:david, :jz).pluck(:id) }
|
||||
|
||||
assert_redirected_to bucket_bubbles_url(buckets(:writebook))
|
||||
assert_redirected_to bubbles_path(bucket_ids: [ buckets(:writebook) ])
|
||||
assert_equal "Writebook bugs", buckets(:writebook).reload.name
|
||||
assert_equal users(:david, :jz), buckets(:writebook).users
|
||||
end
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
require "test_helper"
|
||||
|
||||
class FiltersControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :david
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_difference "users(:david).filters.count", +1 do
|
||||
post filters_url, params: {
|
||||
indexed_by: "popped",
|
||||
assignments: "unassigned",
|
||||
tag_ids: [ tags(:mobile).id ],
|
||||
assignee_ids: [ users(:jz).id ],
|
||||
bucket_ids: [ buckets(:writebook).id ] }
|
||||
end
|
||||
assert_redirected_to bubbles_path(Filter.last.to_params)
|
||||
|
||||
filter = Filter.last
|
||||
assert_predicate filter.indexed_by, :popped?
|
||||
assert_predicate filter.assignments, :unassigned?
|
||||
assert_equal [ tags(:mobile) ], filter.tags
|
||||
assert_equal [ users(:jz) ], filter.assignees
|
||||
assert_equal [ buckets(:writebook) ], filter.buckets
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
assert_difference "users(:david).filters.count", -1 do
|
||||
delete filter_url(filters(:jz_assignments))
|
||||
end
|
||||
assert_redirected_to bubbles_path(filters(:jz_assignments).params)
|
||||
end
|
||||
end
|
||||
Vendored
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
jz_assignments:
|
||||
creator: david
|
||||
tags: mobile
|
||||
assignees: jz
|
||||
fields: <%= { indexed_by: :most_discussed }.to_json %>
|
||||
params: <%= { indexed_by: :most_discussed, tag_ids: [ ActiveRecord::FixtureSet.identify(:mobile) ], assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %>
|
||||
@@ -34,11 +34,42 @@ class BubbleTest < ActiveSupport::TestCase
|
||||
|
||||
test "ordering by activity" do
|
||||
bubbles(:layout).update! boost_count: 1_000
|
||||
assert_equal bubbles(:layout, :logo, :shipping, :text), Bubble.ordered_by_activity.to_a
|
||||
assert_equal bubbles(:layout, :logo, :shipping, :text), Bubble.ordered_by_activity
|
||||
end
|
||||
|
||||
test "ordering by comments" do
|
||||
assert_equal bubbles(:logo, :layout, :shipping, :text), Bubble.ordered_by_comments.to_a
|
||||
assert_equal bubbles(:logo, :layout, :shipping, :text), Bubble.ordered_by_comments
|
||||
end
|
||||
|
||||
test "ordering by boosts" do
|
||||
bubbles(:layout).update! boost_count: 1_000
|
||||
assert_equal bubbles(:layout, :logo, :shipping, :text), Bubble.ordered_by_boosts
|
||||
end
|
||||
|
||||
test "popped" do
|
||||
assert_equal [ bubbles(:shipping) ], Bubble.popped
|
||||
end
|
||||
|
||||
test "active" do
|
||||
assert_equal bubbles(:logo, :layout, :text), Bubble.active
|
||||
end
|
||||
|
||||
test "unassigned" do
|
||||
assert_equal bubbles(:shipping, :text), Bubble.unassigned
|
||||
end
|
||||
|
||||
test "assigned to" do
|
||||
assert_equal bubbles(:logo, :layout), Bubble.assigned_to(users(:jz))
|
||||
end
|
||||
|
||||
test "in bucket" do
|
||||
new_bucket = accounts("37s").buckets.create! name: "New Bucket", creator: users(:david)
|
||||
assert_equal bubbles(:logo, :shipping, :layout, :text), Bubble.in_bucket(buckets(:writebook))
|
||||
assert_empty Bubble.in_bucket(new_bucket)
|
||||
end
|
||||
|
||||
test "tagged with" do
|
||||
assert_equal bubbles(:layout, :text), Bubble.tagged_with(tags(:mobile))
|
||||
end
|
||||
|
||||
test "mentioning" do
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
require "test_helper"
|
||||
|
||||
class FilterTest < ActiveSupport::TestCase
|
||||
test "persistence" do
|
||||
assert_difference "users(:david).filters.count", +1 do
|
||||
filter = users(:david).filters.persist!(indexed_by: "most_boosted")
|
||||
|
||||
assert_changes "filter.reload.updated_at" do
|
||||
assert_equal filter, users(:david).filters.persist!(indexed_by: "most_boosted")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "bubbles" do
|
||||
Current.set session: sessions(:david) do
|
||||
@new_bucket = accounts("37s").buckets.create! name: "Inaccessible Bucket"
|
||||
@new_bubble = @new_bucket.bubbles.create!
|
||||
end
|
||||
|
||||
assert_not_includes users(:kevin).filters.new.bubbles, @new_bubble
|
||||
|
||||
filter = users(:david).filters.new indexed_by: "most_discussed", assignee_ids: [ users(:jz).id ], tag_ids: [ tags(:mobile).id ]
|
||||
assert_equal [ bubbles(:layout) ], filter.bubbles
|
||||
|
||||
filter = users(:david).filters.new assignments: "unassigned", bucket_ids: [ @new_bucket.id ]
|
||||
assert_equal [ @new_bubble ], filter.bubbles
|
||||
|
||||
filter = users(:david).filters.new indexed_by: "popped"
|
||||
assert_equal [ bubbles(:shipping) ], filter.bubbles
|
||||
end
|
||||
|
||||
test "turning into params" do
|
||||
expected = { indexed_by: "most_discussed", tag_ids: [ tags(:mobile).id ], assignee_ids: [ users(:jz).id ], filter_id: filters(:jz_assignments).id }
|
||||
assert_equal expected.stringify_keys, filters(:jz_assignments).to_params.to_h
|
||||
end
|
||||
|
||||
test "param sanitization" do
|
||||
filter = users(:david).filters.new indexed_by: "most_active", tag_ids: "", assignee_ids: [ users(:jz).id ], bucket_ids: [ buckets(:writebook).id ]
|
||||
expected = { assignee_ids: [ users(:jz).id ], bucket_ids: [ buckets(:writebook).id ] }
|
||||
assert_equal expected.stringify_keys, filter.params
|
||||
end
|
||||
|
||||
test "cacheable" do
|
||||
assert_not filters(:jz_assignments).cacheable?
|
||||
assert users(:david).filters.create!(bucket_ids: [ buckets(:writebook).id ]).cacheable?
|
||||
end
|
||||
|
||||
test "resource removal" do
|
||||
filter = users(:david).filters.create! tag_ids: [ tags(:mobile).id ], bucket_ids: [ buckets(:writebook).id ]
|
||||
|
||||
assert_includes filter.params["tag_ids"], tags(:mobile).id
|
||||
assert_includes filter.tags, tags(:mobile)
|
||||
assert_includes filter.params["bucket_ids"], buckets(:writebook).id
|
||||
assert_includes filter.buckets, buckets(:writebook)
|
||||
|
||||
assert_changes "filter.reload.updated_at" do
|
||||
tags(:mobile).destroy!
|
||||
end
|
||||
assert_nil filter.reload.params["tag_ids"]
|
||||
|
||||
assert_changes "Filter.exists?(filter.id)" do
|
||||
buckets(:writebook).destroy!
|
||||
end
|
||||
end
|
||||
|
||||
test "summary" do
|
||||
assert_equal "<mark>Most discussed</mark>, tagged <mark>#Mobile</mark>, and assigned to <mark>JZ</mark> in <mark>all projects</mark>", filters(:jz_assignments).summary
|
||||
end
|
||||
|
||||
test "plain summary" do
|
||||
assert_equal "Most discussed, tagged #Mobile, and assigned to JZ in all projects", filters(:jz_assignments).plain_summary
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user