diff --git a/app/assets/images/close.svg b/app/assets/images/close.svg new file mode 100644 index 000000000..878343d21 --- /dev/null +++ b/app/assets/images/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/filter.svg b/app/assets/images/filter.svg new file mode 100644 index 000000000..b8865e243 --- /dev/null +++ b/app/assets/images/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index ecba266af..df48c288b 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -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); diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/filters.css index 7ca1949ee..95d988c49 100644 --- a/app/assets/stylesheets/filters.css +++ b/app/assets/stylesheets/filters.css @@ -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; diff --git a/app/controllers/bubbles_controller.rb b/app/controllers/bubbles_controller.rb index 7765be897..2c8cf0be5 100644 --- a/app/controllers/bubbles_controller.rb +++ b/app/controllers/bubbles_controller.rb @@ -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 diff --git a/app/controllers/filter_chips_controller.rb b/app/controllers/filter_chips_controller.rb new file mode 100644 index 000000000..d08c906f3 --- /dev/null +++ b/app/controllers/filter_chips_controller.rb @@ -0,0 +1,4 @@ +class FilterChipsController < ApplicationController + def create + end +end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 34a020c4f..1274f11d8 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -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 diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 05caac3d0..1b763e665 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -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 diff --git a/app/javascript/controllers/filter_form_controller.js b/app/javascript/controllers/filter_form_controller.js new file mode 100644 index 000000000..65ac570a1 --- /dev/null +++ b/app/javascript/controllers/filter_form_controller.js @@ -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 + } +} diff --git a/app/models/bubble/assignable.rb b/app/models/bubble/assignable.rb index ac121da87..0b6dc3f9a 100644 --- a/app/models/bubble/assignable.rb +++ b/app/models/bubble/assignable.rb @@ -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) diff --git a/app/models/filter.rb b/app/models/filter.rb index 58edae90f..cfaadb8ed 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -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 diff --git a/app/models/filter/fields.rb b/app/models/filter/fields.rb index 2db92f0e3..a1b385a06 100644 --- a/app/models/filter/fields.rb +++ b/app/models/filter/fields.rb @@ -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 diff --git a/app/models/filter/params.rb b/app/models/filter/params.rb index 2726e153a..71be9eb85 100644 --- a/app/models/filter/params.rb +++ b/app/models/filter/params.rb @@ -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 diff --git a/app/models/filter/resources.rb b/app/models/filter/resources.rb index 5215087f6..78c862c9d 100644 --- a/app/models/filter/resources.rb +++ b/app/models/filter/resources.rb @@ -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 diff --git a/app/models/filter/summarized.rb b/app/models/filter/summarized.rb index 67f194e81..2d406dceb 100644 --- a/app/models/filter/summarized.rb +++ b/app/models/filter/summarized.rb @@ -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 #{assigners.pluck(:name).to_choice_sentence}" + end + end + def bucket_summary if buckets.any? "in #{buckets.pluck(:name).to_choice_sentence}" @@ -33,4 +39,10 @@ module Filter::Summarized "in all projects" end end + + def terms_summary + if terms.any? + "matching #{terms.map { |term| %Q("#{term}") }.to_sentence}" + end + end end diff --git a/app/views/bubbles/_filters.html.erb b/app/views/bubbles/_filters.html.erb index 2efc451c8..49057a0b3 100644 --- a/app/views/bubbles/_filters.html.erb +++ b/app/views/bubbles/_filters.html.erb @@ -1,16 +1,211 @@ -
+

- <%= 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" %>

-

- <%= filter.tags.present? ? "Tagged" : "with" %> - <%= render "bubbles/filters/tags", filter: filter %> +
+ - and <%= render "bubbles/filters/assignments", filter: filter %> -

+ <%= 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 %> +
+ + +
+
+ + +

+ <%= filter.buckets.first&.name || "All projects" %> +

+ +
+ +
+
+ +
+ + + <%= 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 %> +
+ + <%= 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 %> + + + + + <% end %> + +
+
+
+ +
  • Sort by
  • + + <% Filter::INDEXES.each do |index| %> +
  • + <%= 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" } %> +
  • + <% end %> +
    +
    + +
    +
    + +
  • In Project
  • + +
  • + <%= button_to_chip "All projects", data: { action: "filter-form#clearCategory", filter_form_name_param: "bucket_ids[]" } %> +
  • + + <% Current.user.buckets.order(:name).each do |bucket| %> +
  • + <%= button_to_chip bucket.name, params: { text: "in #{bucket.name}", name: "bucket_ids[]", value: bucket.id, frame: :bucket_chips } %> +
  • + <% end %> +
    +
    + +
    +
    + +
  • Tagged
  • + + <% Current.account.tags.order(:title).each do |tag| %> +
  • + <%= button_to_chip tag.title, params: { text: tag.hashtag, name: "tag_ids[]", value: tag.id, frame: :tag_chips } %> +
  • + <% end %> +
    +
    + +
    +
    + +
  • Assigned to…
  • + +
  • + <%= 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[]" } %> +
  • +
  • + <%= 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" } %> +
  • + <% Current.account.users.active.without(Current.user).order(:name).each do |user| %> +
  • + <%= 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" } %> +
  • + <% end %> +
    +
    + +
    +
    + +
  • Assigned by…
  • + +
  • + <%= 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" } %> +
  • + <% Current.account.users.active.without(Current.user).order(:name).each do |user| %> +
  • + <%= 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" } %> +
  • + <% end %> +
    +
    +
    +
    + +
    + <%= tag.button class: "btn", form: :filter_form, formaction: filters_path, formmethod: :post do %> + <%= image_tag "bubbles.svg", aria: { hidden: true }, size: 24 %> + Save to home + <% end %> + + +
    +
    +
    diff --git a/app/views/bubbles/filters/_assignments.html.erb b/app/views/bubbles/filters/_assignments.html.erb deleted file mode 100644 index fb893ebb4..000000000 --- a/app/views/bubbles/filters/_assignments.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -
    - - - - -
  • <%= link_to "No one", bubbles_path(filter.to_params.merge(assignments: :unassigned, assignee_ids: [])), class: "filter__button" %>
  • - - <% Current.account.users.active.order(:name).each do |user| %> -
  • <%= link_to user.name, bubbles_path(filter.to_params.merge(assignments: nil, assignee_ids: [ user.id ])) %>
  • - <% end %> -
    -
    - - <% 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 %> - Clear - <% end %> - <% end %> -
    diff --git a/app/views/bubbles/filters/_bookmark.html.erb b/app/views/bubbles/filters/_bookmark.html.erb deleted file mode 100644 index 5b7b962dc..000000000 --- a/app/views/bubbles/filters/_bookmark.html.erb +++ /dev/null @@ -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 %> - Delete this filter - <% 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 %> - Save this filter - <% end %> - <% end %> -<% end %> diff --git a/app/views/bubbles/filters/_buckets.html.erb b/app/views/bubbles/filters/_buckets.html.erb deleted file mode 100644 index d5537aa07..000000000 --- a/app/views/bubbles/filters/_buckets.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -
    - - - - -
  • <%= link_to "all projects", bubbles_path(filter.to_params.merge(bucket_ids: nil)), class: "filter__button" %>
  • - - <% Current.user.buckets.each do |bucket| %> -
  • <%= link_to bucket.name, bubbles_path(filter.to_params.merge(bucket_ids: [ bucket.id ])), class: "filter__button" %>
  • - <% end %> -
    -
    -
    diff --git a/app/views/bubbles/filters/_index.html.erb b/app/views/bubbles/filters/_index.html.erb deleted file mode 100644 index ff83a8640..000000000 --- a/app/views/bubbles/filters/_index.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -
    - - - - - <% Filter::INDEXES.each do |index| %> -
  • <%= link_to index.humanize, bubbles_path(filter.to_params.merge(indexed_by: index)), class: "filter__button" %>
  • - <% end %> -
    -
    -
    diff --git a/app/views/bubbles/filters/_tags.html.erb b/app/views/bubbles/filters/_tags.html.erb deleted file mode 100644 index 7972e7e2f..000000000 --- a/app/views/bubbles/filters/_tags.html.erb +++ /dev/null @@ -1,20 +0,0 @@ -
    - - - - - <% Current.account.tags.order(:title).each do |tag| %> -
  • <%= link_to tag.title, bubbles_path(filter.to_params.merge(tag_ids: [ tag.id ])) %>
  • - <% end %> -
    -
    - - <% 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 %> - Clear - <% end %> - <% end %> -
    diff --git a/app/views/filter_chips/create.turbo_stream.erb b/app/views/filter_chips/create.turbo_stream.erb new file mode 100644 index 000000000..17d0d268f --- /dev/null +++ b/app/views/filter_chips/create.turbo_stream.erb @@ -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 %> diff --git a/config/routes.rb b/config/routes.rb index 0778fb130..dffa263f2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,7 @@ Rails.application.routes.draw do end resources :filters + resources :filter_chips resource :first_run resource :session diff --git a/db/migrate/20241108205445_hash_filter_params.rb b/db/migrate/20241108205445_hash_filter_params.rb new file mode 100644 index 000000000..e403598d0 --- /dev/null +++ b/db/migrate/20241108205445_hash_filter_params.rb @@ -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 diff --git a/db/migrate/20241113185136_create_filter_assigner_join_table.rb b/db/migrate/20241113185136_create_filter_assigner_join_table.rb new file mode 100644 index 000000000..0ba861f47 --- /dev/null +++ b/db/migrate/20241113185136_create_filter_assigner_join_table.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index bbb0daf2c..a6bac56a8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/test/controllers/filter_chips_controller_test.rb b/test/controllers/filter_chips_controller_test.rb new file mode 100644 index 000000000..8be4c3b6c --- /dev/null +++ b/test/controllers/filter_chips_controller_test.rb @@ -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 diff --git a/test/controllers/filters_controller_test.rb b/test/controllers/filters_controller_test.rb index 2522fde07..8e422fae2 100644 --- a/test/controllers/filters_controller_test.rb +++ b/test/controllers/filters_controller_test.rb @@ -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 diff --git a/test/fixtures/filters.yml b/test/fixtures/filters.yml index b80de3a69..e7134b059 100644 --- a/test/fixtures/filters.yml +++ b/test/fixtures/filters.yml @@ -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) ] }) %> diff --git a/test/models/bubble_test.rb b/test/models/bubble_test.rb index 11e50d926..eae16426a 100644 --- a/test/models/bubble_test.rb +++ b/test/models/bubble_test.rb @@ -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)) diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb index 49c88942d..3b1ec136b 100644 --- a/test/models/filter_test.rb +++ b/test/models/filter_test.rb @@ -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!