Merge pull request #130 from basecamp/unnested-filters
Unnest bubble filters
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 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,15 +0,0 @@
|
||||
module Bucket::View::OrderBy
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :filters, :order_by
|
||||
end
|
||||
|
||||
private
|
||||
ORDERS = {
|
||||
"most_active" => "most active",
|
||||
"most_discussed" => "most discussed",
|
||||
"most_boosted" => "most boosted",
|
||||
"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,28 +1,16 @@
|
||||
<div class="flex flex-column gap-half align-center">
|
||||
<h1 class="txt-large flex align-center gap-half" style="margin-inline-end: calc(var(--btn-size) / -2);">
|
||||
<%= render "bubbles/filters/main", bucket: bucket %>
|
||||
<h1 class="txt-large flex align-center gap-half">
|
||||
<%= render "bubbles/filters/index", filter: filter %>
|
||||
in
|
||||
<%= render "bubbles/filters/bucket", bucket: bucket %>
|
||||
<%= render "bubbles/filters/buckets", filter: filter %>
|
||||
|
||||
<% if false %>
|
||||
<%= button_tag type: :submit, class: "btn txt-small borderless" do %>
|
||||
<%= image_tag "bookmark.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span class="for-screen-reader">Save this filter</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= 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 %>
|
||||
<%= render "bubbles/filters/bookmark", filter: filter %>
|
||||
</h1>
|
||||
|
||||
<h2 class="txt-medium flex align-center justify-center gap-half">
|
||||
<%= filter.tags ? "Tagged" : "with" %>
|
||||
<%= render "bubbles/filters/tags", bucket: bucket, filter: filter %>
|
||||
<%= filter.tags.present? ? "Tagged" : "with" %>
|
||||
<%= render "bubbles/filters/tags", filter: filter %>
|
||||
|
||||
<% unless unassigned_filter_activated? %>
|
||||
and <%= render "bubbles/filters/assignees", bucket: bucket, filter: filter %>
|
||||
<% end %>
|
||||
and <%= render "bubbles/filters/assignments", filter: filter %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -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 "Filter.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 "Filter.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 "Filter.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