@@ -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 |
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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) ] }) %>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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!
|
||||
|
||||
Reference in New Issue
Block a user