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:
Jose Farias
2024-11-07 16:55:00 -06:00
58 changed files with 683 additions and 445 deletions
+8 -20
View File
@@ -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
+4 -3
View File
@@ -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
+30
View File
@@ -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
+14 -48
View File
@@ -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
View File
@@ -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
+3 -5
View File
@@ -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 -1
View File
@@ -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 }
-34
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
module Bucket::Filterable
def bubble_filter_from(params = {})
Bucket::BubbleFilter.new self, params
end
end
-27
View File
@@ -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
-12
View File
@@ -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
-14
View File
@@ -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
-10
View File
@@ -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
-24
View File
@@ -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
-12
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
module Bucket::Views
extend ActiveSupport::Concern
included do
has_many :views, dependent: :delete_all
end
end
+15
View File
@@ -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
+36
View File
@@ -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
+30
View File
@@ -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
+37
View File
@@ -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
+15
View File
@@ -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
+36
View File
@@ -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
View File
@@ -1,4 +1,4 @@
class Pop < ApplicationRecord
belongs_to :bubble
belongs_to :bubble, touch: true
belongs_to :user, optional: true
end
+2
View File
@@ -1,4 +1,6 @@
class Tag < ApplicationRecord
include Filterable
belongs_to :account
has_many :taggings, dependent: :destroy
+4 -3
View File
@@ -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
-7
View File
@@ -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 -1
View File
@@ -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>
@@ -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>
-17
View File
@@ -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>
+6 -6
View File
@@ -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 %>
+16 -10
View File
@@ -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>
+4 -2
View File
@@ -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 -2
View File
@@ -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">
+20 -21
View File
@@ -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 %>
-29
View File
@@ -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>
+2 -2
View File
@@ -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>
+4 -9
View File
@@ -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>
+26
View File
@@ -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
View File
@@ -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
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2024_10_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
+48 -3
View File
@@ -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
+2 -2
View File
@@ -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
View File
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+6
View File
@@ -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 %>
+33 -2
View File
@@ -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
+73
View File
@@ -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