Merge pull request #131 from basecamp/new-filters

New filtering UI
This commit is contained in:
Jose Farias
2024-11-13 14:32:21 -06:00
committed by GitHub
31 changed files with 426 additions and 228 deletions
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m14.3 12.179a.25.25 0 0 1 0-.354l9.263-9.262a1.5 1.5 0 0 0 -2.124-2.121l-9.262 9.258a.25.25 0 0 1 -.354 0l-9.262-9.258a1.5 1.5 0 0 0 -2.122 2.121l9.261 9.262a.25.25 0 0 1 0 .354l-9.261 9.263a1.5 1.5 0 0 0 2.122 2.121l9.262-9.263a.25.25 0 0 1 .354 0l9.262 9.263a1.5 1.5 0 0 0 2.122-2.121z"/></svg>

After

Width:  |  Height:  |  Size: 365 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="m16 10h-.00000009c1.10457-.00000005 2 .89543 2 2v24.3674l.00000002.00000001c3.11083 1.08563 4.75257 4.48754 3.66694 7.59836-.599357 1.71743-1.94951 3.06758-3.66694 3.66694v4.3673c0 1.10457-.895431 2-2 2-1.10457 0-2-.895431-2-2v-4.3673l.00000016.00000005c-3.11083-1.08563-4.75257-4.48754-3.66694-7.59836.599357-1.71743 1.94951-3.06758 3.66694-3.66694v-24.3674.0000003c-.00000017-1.10457.89543-2 2-2h.00000018zm16 0h-.00000009c1.10457-.00000005 2 .89543 2 2v4.3673l.00000002.00000001c3.11083 1.08563 4.75257 4.48754 3.66694 7.59836-.599357 1.71743-1.94951 3.06758-3.66694 3.66694v24.3674c0 1.10457-.895431 2-2 2-1.10457 0-2-.895431-2-2v-24.3673l.00000016.00000005c-3.11083-1.08563-4.75257-4.48754-3.66694-7.59836.599357-1.71743 1.94951-3.06758 3.66694-3.66694v-4.3674.0000003c-.00000017-1.10457.89543-2 2-2h.00000018zm16 0h-.00000009c1.10457-.00000005 2 .89543 2 2v14.3674l.00000002.00000001c3.11083 1.08563 4.75257 4.48754 3.66694 7.59836-.599357 1.71743-1.94951 3.06758-3.66694 3.66694v14.3673c0 1.10457-.895431 2-2 2-1.10457 0-2-.895431-2-2v-14.3673l.00000016.00000005c-3.11083-1.08563-4.75257-4.48754-3.66694-7.59836.599357-1.71743 1.94951-3.06758 3.66694-3.66694v-14.3674.0000003c-.00000017-1.10457.89543-2 2-2h.00000018z" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -211,6 +211,10 @@
--outline-color: var(--color-positive);
}
.btn--remove {
--btn-icon-size: 0.7em;
}
.btn--reversed {
--btn-background: var(--color-ink);
--btn-color: var(--color-bg);
+16 -35
View File
@@ -1,50 +1,31 @@
.filter {
display: inline-flex;
position: relative;
.filters {
#header:has(&) {
position: relative;
z-index: 1;
}
}
.filter--active {
li {
display: none;
&.selected {
display: flex;
}
}
}
.filter__button {
--hover-size: 0;
border-radius: 0.5em;
color: var(--color-ink);
display: block;
overflow: hidden;
padding: 0.3em 0.7em;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-link);
text-decoration: underline;
text-decoration-skip-ink: auto;
}
.filter__menu {
a {
border-radius: 0.5em;
color: var(--color-ink);
display: block;
overflow: hidden;
padding: 0.3em 0.7em;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
@media (hover: hover) {
&:hover {
background-color: var(--color-selected);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-selected);
}
}
}
.filter__label {
padding: 0.3em 0.7em;
}
.filter__popup {
--panel-border-radius: 0.5em;
--panel-padding: 0.5em;
+1 -2
View File
@@ -8,7 +8,6 @@ class BubblesController < ApplicationController
def index
@bubbles = @filter.bubbles
@bubbles = @bubbles.mentioning(params[:term]) if params[:term].present?
end
def create
@@ -30,7 +29,7 @@ class BubblesController < ApplicationController
private
def set_filter
@filter = Current.user.filters.build params.permit(*Filter::KNOWN_PARAMS)
@filter = Current.user.filters.build params.permit(*Filter::PERMITTED_PARAMS)
end
def set_bubble
@@ -0,0 +1,4 @@
class FilterChipsController < ApplicationController
def create
end
end
+1 -1
View File
@@ -17,7 +17,7 @@ class FiltersController < ApplicationController
end
def filter_params
params.permit(*Filter::KNOWN_PARAMS).compact_blank
params.permit(*Filter::PERMITTED_PARAMS).compact_blank
end
def redirect_after_destroy
+14 -19
View File
@@ -1,27 +1,22 @@
module FiltersHelper
def buckets_filter_text(filter)
if filter.buckets.present?
filter.buckets.map(&:name).to_choice_sentence
else
"all projects"
def filter_chip_id(name, value)
"#{name}__filter--#{value}"
end
def filter_chip_tag(text, name:, value:, **options)
tag.button id: filter_chip_id(name, value), class: [ "btn txt-small btn--remove", options.delete(:class) ],
data: { action: "filter-form#removeFilter form#submit", filter_form_target: "button" } do
concat hidden_field_tag(name, value, id: nil)
concat tag.span(text)
concat image_tag("close.svg", aria: { hidden: true }, size: 24)
end
end
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"
def button_to_chip(text, params: {}, data: {})
if params.present?
button_to text, filter_chips_path, method: :post, class: "btn btn--plain filter__button", params: params, data: data
else
"assigned to anyone"
end
end
def tags_filter_text(filter)
if filter.tags.present?
filter.tags.map(&:hashtag).to_choice_sentence
else
"any tag"
button_tag text, type: :button, class: "btn btn--plain filter__button", data: data
end
end
end
@@ -0,0 +1,28 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "button" ]
connect() {
this.buttonTargets.forEach(button => this.#showButton(button))
}
removeFilter(event) {
event.preventDefault()
this.#hideButton(event.target.closest("button"))
}
clearCategory({ params: { name } }) {
this.element.querySelectorAll(`input[name="${name}"]`).forEach(input => this.#hideButton(input.closest("button")))
}
#showButton(button) {
button.querySelector("input").disabled = false
button.hidden = false
}
#hideButton(button) {
button.querySelector("input").disabled = true
button.hidden = true
}
}
+2 -1
View File
@@ -6,7 +6,8 @@ module Bubble::Assignable
has_many :assignees, through: :assignments
scope :unassigned, -> { where.missing :assignments }
scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }) }
scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct }
scope :assigned_by, ->(users) { joins(:assignments).where(assignments: { assigner: users }).distinct }
end
def assign(users, assigner: Current.user)
+15 -4
View File
@@ -6,12 +6,18 @@ class Filter < ApplicationRecord
class << self
def persist!(attrs)
filter = new(attrs)
filter.save!
filter
create!(attrs)
rescue ActiveRecord::RecordNotUnique
find_by!(params: filter.params).tap(&:touch)
find_by!(params_digest: digest_params(attrs)).tap(&:touch)
end
def digest_params(params)
Digest::MD5.hexdigest params.sort.to_json
end
end
def empty?
as_params.blank?
end
def bubbles
@@ -20,8 +26,13 @@ class Filter < ApplicationRecord
result = result.active unless indexed_by.popped?
result = result.unassigned if assignments.unassigned?
result = result.assigned_to(assignees.ids) if assignees.present?
result = result.assigned_by(assigners.ids) if assigners.present?
result = result.in_bucket(buckets.ids) if buckets.present?
result = result.tagged_with(tags.ids) if tags.present?
result = terms.reduce(result) do |result, term|
result.mentioning(term)
end
result
end
end
+13 -13
View File
@@ -1,28 +1,28 @@
module Filter::Fields
extend ActiveSupport::Concern
INDEXES = %w[ most_active most_discussed most_boosted newest oldest popped ]
INDEXES = %w[ most_discussed most_boosted newest oldest popped ]
class_methods do
def default_fields
{ "indexed_by" => "most_active" }
{ indexed_by: "most_active" }
end
end
def assignments=(value)
fields["assignments"] = value
end
included do
store_accessor :fields, :indexed_by, :assignments, :terms
def assignments
fields["assignments"].to_s.inquiry
end
def indexed_by
(super || default_fields[:indexed_by]).inquiry
end
def indexed_by=(value)
fields["indexed_by"] = value
end
def assignments
super.to_s.inquiry
end
def indexed_by
(fields["indexed_by"] || default_fields["indexed_by"]).inquiry
def terms
Array(super)
end
end
private
+15 -25
View File
@@ -1,37 +1,27 @@
module Filter::Params
extend ActiveSupport::Concern
KNOWN_PARAMS = [ :indexed_by, :assignments, bucket_ids: [], assignee_ids: [], tag_ids: [] ]
PERMITTED_PARAMS = [ :indexed_by, :assignments, bucket_ids: [], assignee_ids: [], assigner_ids: [], tag_ids: [], terms: [] ]
included do
after_initialize :derive_params
before_save { self.params_digest = self.class.digest_params(as_params) }
end
def as_params
@as_params ||= {
terms: terms,
tag_ids: tags.ids,
indexed_by: indexed_by,
bucket_ids: buckets.ids,
assignments: assignments,
assignee_ids: assignees.ids,
assigner_ids: assigners.ids
}.reject { |k, v| default_fields[k] == v }.compact_blank
end
def to_params
ActionController::Parameters.new(params).permit(*KNOWN_PARAMS).tap do |params|
ActionController::Parameters.new(as_params).permit(*PERMITTED_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
+2 -1
View File
@@ -5,11 +5,12 @@ module Filter::Resources
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"
has_and_belongs_to_many :assigners, class_name: "User", join_table: "assigners_filters", association_foreign_key: "assigner_id"
end
def resource_removed(resource)
kind = resource.class.model_name.plural
send "#{kind}=", send(kind).without(resource)
derived_params.blank? ? destroy! : save!
empty? ? destroy! : save!
end
end
+13 -1
View File
@@ -1,6 +1,6 @@
module Filter::Summarized
def summary
[ index_summary, tag_summary, assignee_summary ].compact.to_sentence + " #{bucket_summary}"
[ index_summary, tag_summary, assignee_summary, assigner_summary, terms_summary ].compact.to_sentence + " #{bucket_summary}"
end
def plain_summary
@@ -26,6 +26,12 @@ module Filter::Summarized
end
end
def assigner_summary
if assigners.any?
"assigned by <mark>#{assigners.pluck(:name).to_choice_sentence}</mark>"
end
end
def bucket_summary
if buckets.any?
"in <mark>#{buckets.pluck(:name).to_choice_sentence}</mark>"
@@ -33,4 +39,10 @@ module Filter::Summarized
"in <mark>all projects</mark>"
end
end
def terms_summary
if terms.any?
"matching <mark>#{terms.map { |term| %Q("#{term}") }.to_sentence}</mark>"
end
end
end
+206 -11
View File
@@ -1,16 +1,211 @@
<div class="flex flex-column gap-half align-center">
<div class="flex flex-column gap-half align-center" data-controller="dialog filter-form" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside turbo:before-cache@document->dialog#close">
<h1 class="txt-large flex align-center gap-half">
<%= render "bubbles/filters/index", filter: filter %>
in
<%= render "bubbles/filters/buckets", filter: filter %>
<%= render "bubbles/filters/bookmark", filter: filter %>
<%= filter.buckets.first&.name || "All projects" %>
</h1>
<h2 class="txt-medium flex align-center justify-center gap-half">
<%= filter.tags.present? ? "Tagged" : "with" %>
<%= render "bubbles/filters/tags", filter: filter %>
<div class="filters flex-inline align-center gap-half">
<button class="btn btn--plain txt-small" data-action="click->dialog#toggle">
<%= image_tag "filter.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Filter</span>
</button>
and <%= render "bubbles/filters/assignments", filter: filter %>
</h2>
<%= form_with url: bubbles_path, method: :get, class: "flex-inline center align-center gap-half", data: { controller: "form" } do %>
<%= filter_chip_tag filter.indexed_by.humanize, name: "indexed_by", value: filter.indexed_by %>
<% filter.tags.each do |tag| %>
<%= filter_chip_tag tag.hashtag, name: "tag_ids[]", value: tag.id %>
<% end %>
<% filter.assignees.each do |assignee| %>
<%= filter_chip_tag "for #{assignee.name}", name: "assignee_ids[]", value: assignee.id %>
<% end %>
<% if filter.assignments.present? %>
<%= filter_chip_tag filter.assignments.humanize, name: "assignments", value: filter.assignments %>
<% end %>
<% filter.assigners.each do |assigner| %>
<%= filter_chip_tag "by #{assigner.name}", name: "assigner_ids[]", value: assigner.id %>
<% end %>
<% filter.buckets.each do |bucket| %>
<%= filter_chip_tag "in #{bucket.name}", name: "bucket_ids[]", value: bucket.id %>
<% end %>
<% filter.terms.each do |term| %>
<%= filter_chip_tag %Q("#{term}"), name: "terms[]", value: term %>
<% end %>
<% end %>
</div>
<dialog class="panel fill-white shadow" style="--panel-padding: var(--block-space); --panel-size: 90ch;" data-dialog-target="dialog">
<div class="flex flex-column gap-half">
<div class="flex align-center gap-half justify-space-between">
<span class="btn btn--placeholder txt-small" aria-hidden="true"></span>
<h2 class="txt-large flex gap-half margin-none">
<%= filter.buckets.first&.name || "All projects" %>
</h2>
<form method="dialog">
<button class="btn panel__close txt-small" title="Close (esc)">
<%= image_tag "remove.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Close</span>
</button>
</form>
</div>
<div class="flex-inline center align-center gap-half margin-block-half">
<button class="btn btn--plain txt-small" data-action="click->dialog#toggle">
<%= image_tag "filter.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Filter</span>
</button>
<%= form_with url: bubbles_path, id: :filter_form, method: :get, class: "flex-inline center align-center gap-half" do %>
<%= turbo_frame_tag :indexed_by_chips do %>
<%= filter_chip_tag filter.indexed_by.humanize, name: "indexed_by", value: filter.indexed_by, class: "fill-selected" %>
<% end %>
<%= turbo_frame_tag :tag_chips do %>
<% filter.tags.each do |tag| %>
<%= filter_chip_tag tag.hashtag, name: "tag_ids[]", value: tag.id, class: "fill-selected" %>
<% end %>
<% end %>
<%= turbo_frame_tag :assignee_chips do %>
<% filter.assignees.each do |assignee| %>
<%= filter_chip_tag "for #{assignee.name}", name: "assignee_ids[]", value: assignee.id, class: "fill-selected" %>
<% end %>
<% if filter.assignments.present? %>
<%= filter_chip_tag filter.assignments.humanize, name: "assignments", value: filter.assignments, class: "fill-selected" %>
<% end %>
<% end %>
<%= turbo_frame_tag :assigner_chips do %>
<% filter.assigners.each do |assigner| %>
<%= filter_chip_tag "by #{assigner.name}", name: "assigner_ids[]", value: assigner.id, class: "fill-selected" %>
<% end %>
<% end %>
<%= turbo_frame_tag :bucket_chips do %>
<% filter.buckets.each do |bucket| %>
<%= filter_chip_tag "in #{bucket.name}", name: "bucket_ids[]", value: bucket.id, class: "fill-selected" %>
<% end %>
<% end %>
<%= turbo_frame_tag :terms_chips do %>
<% filter.terms.each do |term| %>
<%= filter_chip_tag %Q("#{term}"), name: "terms[]", value: term, class: "fill-selected" %>
<% end %>
<% end %>
<% end %>
</div>
<%= form_with url: filter_chips_path, method: :post, class: "flex gap flex-item-grow align-center justify-center full-width center margin-block" do %>
<%= hidden_field_tag :name, "terms[]" %>
<%= hidden_field_tag :frame, :terms_chips %>
<input name="value" type="text" class="input full-width" placeholder="Type to match words…" autocomplete="off" />
<button class="btn txt-small" type="submit">
<span class="for-screen-reader">Submit text filter</span>
<%= image_tag "add.svg", aria: { hidden: true }, size: 24 %>
</button>
<% end %>
<div class="flex flex-column gap full-width" style="--border-color: var(--color-subtle)">
<div class="flex gap">
<div class="flex gap flex-item-grow">
<menu class="filter__menu list-style-none unpad margin-none">
<li><strong class="filter__label">Sort by</strong></li>
<% Filter::INDEXES.each do |index| %>
<li>
<%= button_to_chip index.humanize, params: { text: index.humanize, name: "indexed_by", value: index, frame: :indexed_by_chips }, data: { action: "filter-form#clearCategory", filter_form_name_param: "indexed_by" } %>
</li>
<% end %>
</menu>
</div>
<hr class="separator" />
<div class="flex gap flex-item-grow">
<menu class="filter__menu list-style-none unpad margin-none">
<li><strong class="filter__label">In Project</strong></li>
<li>
<%= button_to_chip "All projects", data: { action: "filter-form#clearCategory", filter_form_name_param: "bucket_ids[]" } %>
</li>
<% Current.user.buckets.order(:name).each do |bucket| %>
<li>
<%= button_to_chip bucket.name, params: { text: "in #{bucket.name}", name: "bucket_ids[]", value: bucket.id, frame: :bucket_chips } %>
</li>
<% end %>
</menu>
</div>
<hr class="separator" />
<div class="flex gap flex-item-grow">
<menu class="filter__menu list-style-none unpad margin-none">
<li><strong class="filter__label">Tagged</strong></li>
<% Current.account.tags.order(:title).each do |tag| %>
<li>
<%= button_to_chip tag.title, params: { text: tag.hashtag, name: "tag_ids[]", value: tag.id, frame: :tag_chips } %>
</li>
<% end %>
</menu>
</div>
<hr class="separator" />
<div class="flex gap flex-item-grow">
<menu class="filter__menu list-style-none unpad margin-none">
<li><strong class="filter__label">Assigned to…</strong></li>
<li>
<%= button_to_chip "No one", params: { text: "Unassigned", name: "assignments", value: "unassigned", frame: :assignee_chips }, data: { action: "filter-form#clearCategory", filter_form_name_param: "assignee_ids[]" } %>
</li>
<li>
<%= button_to_chip "Me", params: { text: "for #{Current.user.name}", name: "assignee_ids[]", value: Current.user.id, frame: :assignee_chips }, data: { action: "filter-form#clearCategory", filter_form_name_param: "assignments" } %>
</li>
<% Current.account.users.active.without(Current.user).order(:name).each do |user| %>
<li>
<%= button_to_chip user.name, params: { text: "for #{user.name}", name: "assignee_ids[]", value: user.id, frame: :assignee_chips }, data: { action: "filter-form#clearCategory", filter_form_name_param: "assignments" } %>
</li>
<% end %>
</menu>
</div>
<hr class="separator" />
<div class="flex gap flex-item-grow">
<menu class="filter__menu list-style-none unpad margin-none">
<li><strong class="filter__label">Assigned by…</strong></li>
<li>
<%= button_to_chip "Me", params: { text: "by #{Current.user.name}", name: "assigner_ids[]", value: Current.user.id, frame: :assigner_chips }, data: { action: "filter-form#clearCategory", filter_form_name_param: "assignments" } %>
</li>
<% Current.account.users.active.without(Current.user).order(:name).each do |user| %>
<li>
<%= button_to_chip user.name, params: { text: "by #{user.name}", name: "assigner_ids[]", value: user.id, frame: :assigner_chips }, data: { action: "filter-form#clearCategory", filter_form_name_param: "assignments" } %>
</li>
<% end %>
</menu>
</div>
</div>
</div>
<div class="flex align-center txt-align-center justify-space-between gap-half margin-block-start">
<%= tag.button class: "btn", form: :filter_form, formaction: filters_path, formmethod: :post do %>
<%= image_tag "bubbles.svg", aria: { hidden: true }, size: 24 %>
<span>Save to home</span>
<% end %>
<button class="btn btn--reversed" form="filter_form">
<span>Apply</span>
<%= image_tag "arrow-right.svg", aria: { hidden: true }, size: 24 %>
</button>
</div>
</div>
</dialog>
</div>
@@ -1,22 +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">
<%= 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>
@@ -1,34 +0,0 @@
<% 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,15 +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">
<%= 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>
-13
View File
@@ -1,13 +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">
<%= filter.indexed_by.humanize %>
</button>
<dialog class="filter__popup panel fill-white shadow" data-dialog-target="dialog" data-turbo-temporary>
<menu class="filter__menu unpad margin-none">
<% 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>
</div>
-20
View File
@@ -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">
<%= tags_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">
<% 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.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 %>
<% end %>
</div>
@@ -0,0 +1,7 @@
<% text = params[:text] || %Q("#{params[:value]}") %>
<%= turbo_stream.remove filter_chip_id(params[:name], params[:value]) %>
<%= turbo_stream.append params[:frame] do %>
<%= filter_chip_tag text, name: params[:name], value: params[:value], class: "fill-selected" %>
<% end %>
+1
View File
@@ -33,6 +33,7 @@ Rails.application.routes.draw do
end
resources :filters
resources :filter_chips
resource :first_run
resource :session
@@ -0,0 +1,7 @@
class HashFilterParams < ActiveRecord::Migration[8.0]
def change
change_column :filters, :params, :string, null: false
change_column_default :filters, :params, from: {}, to: nil
rename_column :filters, :params, :params_digest
end
end
@@ -0,0 +1,8 @@
class CreateFilterAssignerJoinTable < ActiveRecord::Migration[8.0]
def change
create_join_table :filters, :assigners do |t|
t.index :filter_id
t.index :assigner_id
end
end
end
Generated
+10 -3
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_11_05_224305) do
ActiveRecord::Schema[8.0].define(version: 2024_11_13_185136) do
create_table "accesses", force: :cascade do |t|
t.integer "bucket_id", null: false
t.integer "user_id", null: false
@@ -65,6 +65,13 @@ ActiveRecord::Schema[8.0].define(version: 2024_11_05_224305) do
t.index ["filter_id"], name: "index_assignees_filters_on_filter_id"
end
create_table "assigners_filters", id: false, force: :cascade do |t|
t.integer "filter_id", null: false
t.integer "assigner_id", null: false
t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id"
t.index ["filter_id"], name: "index_assigners_filters_on_filter_id"
end
create_table "assignments", force: :cascade do |t|
t.integer "assignee_id", null: false
t.integer "bubble_id", null: false
@@ -131,11 +138,11 @@ ActiveRecord::Schema[8.0].define(version: 2024_11_05_224305) do
create_table "filters", force: :cascade do |t|
t.integer "creator_id", null: false
t.json "params", default: {}, null: false
t.string "params_digest", 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
t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true
end
create_table "filters_tags", id: false, force: :cascade do |t|
@@ -0,0 +1,16 @@
require "test_helper"
class FilterChipsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
post filter_chips_url(format: :turbo_stream), params: { text: "for David", name: "assignee_ids[]", value: users(:david).id, frame: "assignee_chips" }
assert_response :success
assert_turbo_stream action: :remove, target: "assignee_ids[]__filter--#{users(:david).id}"
assert_turbo_stream action: :append, target: "assignee_chips"
assert_select "button", text: "for David"
end
end
+1 -1
View File
@@ -28,6 +28,6 @@ class FiltersControllerTest < ActionDispatch::IntegrationTest
assert_difference "users(:david).filters.count", -1 do
delete filter_url(filters(:jz_assignments))
end
assert_redirected_to bubbles_path(filters(:jz_assignments).params)
assert_redirected_to bubbles_path(filters(:jz_assignments).as_params)
end
end
+1 -1
View File
@@ -3,4 +3,4 @@ jz_assignments:
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 %>
params_digest: <%= Filter.digest_params({ indexed_by: :most_discussed, tag_ids: [ ActiveRecord::FixtureSet.identify(:mobile) ], assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }) %>
+4
View File
@@ -62,6 +62,10 @@ class BubbleTest < ActiveSupport::TestCase
assert_equal bubbles(:logo, :layout), Bubble.assigned_to(users(:jz))
end
test "assigned by" do
assert_equal bubbles(:layout, :logo), Bubble.assigned_by(users(:david))
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))
+35 -6
View File
@@ -3,10 +3,10 @@ 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")
filter = users(:david).filters.persist!(indexed_by: "most_boosted", tag_ids: [ tags(:mobile).id ])
assert_changes "filter.reload.updated_at" do
assert_equal filter, users(:david).filters.persist!(indexed_by: "most_boosted")
assert_equal filter, users(:david).filters.persist!(indexed_by: "most_boosted", tag_ids: [ tags(:mobile).id ])
end
end
end
@@ -15,6 +15,9 @@ class FilterTest < ActiveSupport::TestCase
Current.set session: sessions(:david) do
@new_bucket = accounts("37s").buckets.create! name: "Inaccessible Bucket"
@new_bubble = @new_bucket.bubbles.create!
bubbles(:layout).capture Comment.new(body: "I hate haggis")
bubbles(:logo).capture Comment.new(body: "I love haggis")
end
assert_not_includes users(:kevin).filters.new.bubbles, @new_bubble
@@ -22,9 +25,18 @@ class FilterTest < ActiveSupport::TestCase
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 assigner_ids: [ users(:david).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 terms: [ "haggis" ]
assert_equal bubbles(:logo, :layout), filter.bubbles
filter = users(:david).filters.new terms: [ "haggis", "love" ]
assert_equal [ bubbles(:logo) ], filter.bubbles
filter = users(:david).filters.new indexed_by: "popped"
assert_equal [ bubbles(:shipping) ], filter.bubbles
end
@@ -37,7 +49,7 @@ class FilterTest < ActiveSupport::TestCase
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
assert_equal expected, filter.as_params
end
test "cacheable" do
@@ -45,18 +57,35 @@ class FilterTest < ActiveSupport::TestCase
assert users(:david).filters.create!(bucket_ids: [ buckets(:writebook).id ]).cacheable?
end
test "default fields" do
assert_equal "most_active", users(:david).filters.new.indexed_by
end
test "indexed by" do
assert_predicate users(:david).filters.new(indexed_by: "most_discussed").indexed_by, :most_discussed?
end
test "assignments" do
assert_predicate users(:david).filters.new(assignments: "unassigned").assignments, :unassigned?
end
test "terms" do
assert_empty users(:david).filters.new.terms
assert_includes users(:david).filters.new(terms: [ "haggis" ]).terms, "haggis"
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.as_params[:tag_ids], tags(:mobile).id
assert_includes filter.tags, tags(:mobile)
assert_includes filter.params["bucket_ids"], buckets(:writebook).id
assert_includes filter.as_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_nil Filter.find(filter.id).as_params["tag_ids"] # can't reload because as_params is memoized
assert_changes "Filter.exists?(filter.id)" do
buckets(:writebook).destroy!