-
+ |
<%= render component('ui/table/pagination').new(
prev_link: @prev_page_link,
diff --git a/admin/app/components/solidus_admin/ui/table/component.rb b/admin/app/components/solidus_admin/ui/table/component.rb
index f9f576461c7..a850c423f68 100644
--- a/admin/app/components/solidus_admin/ui/table/component.rb
+++ b/admin/app/components/solidus_admin/ui/table/component.rb
@@ -5,6 +5,7 @@ class SolidusAdmin::UI::Table::Component < SolidusAdmin::BaseComponent
# @param model_class [ActiveModel::Translation] The model class used for translations.
# @param rows [Array] The collection of objects that will be passed to columns for display.
# @param fade_row_proc [Proc, nil] A proc determining if a row should have a faded appearance.
+ # @param search_param [Symbol] The param for searching.
# @param search_key [Symbol] The key for searching.
# @param search_url [String] The base URL for searching.
#
@@ -19,11 +20,14 @@ class SolidusAdmin::UI::Table::Component < SolidusAdmin::BaseComponent
# @option batch_actions [String] :action The batch action path.
# @option batch_actions [String] :method The batch action HTTP method for the provided path.
#
- #
- # @param filters [Array ] The array of filter definitions.
- # @option filters [String] :name The filter's name.
- # @option filters [Any] :value The filter's value.
- # @option filters [String] :label The filter's label.
+ # @param filters [Array] The list of filter configurations to render.
+ # @option filters [String] :presentation The display name of the filter dropdown.
+ # @option filters [String] :combinator The combining logic of the filter dropdown.
+ # @option filters [String] :attribute The database attribute this filter modifies.
+ # @option filters [String] :predicate The predicate used for this filter (e.g., "eq" for equals).
+ # @option filters [Array] :options An array of arrays, each containing two elements:
+ # 1. A human-readable presentation of the filter option (e.g., "Active").
+ # 2. The actual value used for filtering (e.g., "active").
#
# @param prev_page_link [String, nil] The link to the previous page.
# @param next_page_link [String, nil] The link to the next page.
@@ -31,8 +35,7 @@ def initialize(
id:,
model_class:,
rows:,
- search_key:,
- search_url:,
+ search_key:, search_url:, search_param: :q,
fade_row_proc: nil,
columns: [],
batch_actions: [],
@@ -47,6 +50,7 @@ def initialize(
@model_class = model_class
@rows = rows
@fade_row_proc = fade_row_proc
+ @search_param = search_param
@search_key = search_key
@search_url = search_url
@prev_page_link = prev_page_link
@@ -112,6 +116,19 @@ def render_batch_action_button(batch_action)
)
end
+ def render_ransack_filter_dropdown(filter, index)
+ render component("ui/table/ransack_filter").new(
+ presentation: filter.presentation,
+ search_param: @search_param,
+ combinator: filter.combinator,
+ attribute: filter.attribute,
+ predicate: filter.predicate,
+ options: filter.options,
+ form: search_form_id,
+ index: index,
+ )
+ end
+
def render_header_cell(cell, **attrs)
cell = cell.call if cell.respond_to?(:call)
cell = @model_class.human_attribute_name(cell) if cell.is_a?(Symbol)
@@ -133,18 +150,17 @@ def render_data_cell(cell, data)
cell = data.public_send(cell) if cell.is_a?(Symbol)
cell = cell.render_in(self) if cell.respond_to?(:render_in)
- content_tag(:td, content_tag(:div, cell, class: "flex items-center gap-1.5"), class: "py-2 px-4 h-10 vertical-align-middle leading-none")
- end
-
- def row_class_for(row)
- classes = ['border-b', 'border-gray-100']
- classes << ['bg-gray-15', 'text-gray-700'] if @fade_row_proc&.call(row)
-
- classes.join(' ')
+ tag.td(
+ tag.div(cell, class: "flex items-center gap-1.5"),
+ class: "
+ py-2 px-4 h-10 vertical-align-middle leading-none
+ [tr:last-child_&:first-child]:rounded-bl-lg [tr:last-child_&:last-child]:rounded-br-lg
+ ",
+ )
end
Column = Struct.new(:header, :data, :class_name, keyword_init: true)
BatchAction = Struct.new(:display_name, :icon, :action, :method, keyword_init: true) # rubocop:disable Lint/StructNewOverride
- Filter = Struct.new(:name, :value, :label, keyword_init: true)
+ Filter = Struct.new(:presentation, :combinator, :attribute, :predicate, :options, keyword_init: true)
private_constant :Column, :BatchAction, :Filter
end
diff --git a/admin/app/components/solidus_admin/ui/table/ransack_filter/component.html.erb b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.html.erb
new file mode 100644
index 00000000000..1a6492f0e30
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.html.erb
@@ -0,0 +1,73 @@
+
+
+
+ -target="details">
+ -target="summary">
+ <%= @presentation %>
+ <%= render component("ui/icon").new(name: 'arrow-down-s-fill', class: "w-[1.4em] h-[1.4em]") %>
+
+
+
+
+ <% if @selections.size > 6 %>
+
+
+
+ <% end %>
+ -target="menu">
+ <% if @selections.any? %>
+ <% @selections.each do |selection| %>
+ -target="option">
+
+
+
+ <%= render component('ui/forms/checkbox').new(
+ id: selection.id,
+ name: selection.option.name,
+ value: selection.option.value,
+ checked: selection.checked,
+ size: :s,
+ form: @form,
+ "data-action": "#{stimulus_id}#search #{stimulus_id}#sortCheckboxes",
+ "data-#{stimulus_id}-target": "checkbox"
+ ) %>
+
+ <%= label_tag selection.id, selection.presentation, class: "ml-2 text-sm text-gray-700" %>
+
+ <% end %>
+ <% else %>
+
+ <%= t('.no_filter_options') %>
+
+ <% end %>
+
+
+
+
+
diff --git a/admin/app/components/solidus_admin/ui/table/ransack_filter/component.js b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.js
new file mode 100644
index 00000000000..ac9489cea99
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.js
@@ -0,0 +1,62 @@
+import { Controller } from '@hotwired/stimulus'
+import { useClickOutside, useDebounce } from 'stimulus-use'
+
+const BG_GRAY = 'bg-gray-100'
+
+export default class extends Controller {
+ static targets = ['details', 'summary', 'option', 'checkbox', 'menu']
+ static debounces = ['init']
+
+ connect() {
+ useDebounce(this, { wait: 50 })
+ useClickOutside(this)
+ this.init()
+ }
+
+ clickOutside(event) {
+ this.detailsTarget.removeAttribute("open")
+ }
+
+ init() {
+ this.highlightFilter()
+ this.showSearch()
+ }
+
+ highlightFilter() {
+ const optionIsSelected = this.isAnyCheckboxChecked()
+ this.summaryTarget.classList.toggle(BG_GRAY, optionIsSelected)
+ }
+
+ showSearch() {
+ if (this.isAnyCheckboxChecked())
+ this.dispatch("showSearch")
+ }
+
+ filterOptions(event) {
+ const query = event.currentTarget.value.toLowerCase()
+ this.optionTargets.forEach((option) => {
+ option.style.display = option.textContent.toLowerCase().includes(query) ? 'block' : 'none'
+ })
+ }
+
+ search() {
+ this.dispatch("search")
+ this.highlightFilter()
+ }
+
+ sortCheckboxes() {
+ const checkboxes = this.checkboxTargets
+
+ checkboxes.sort((a, b) => {
+ if (a.checked && !b.checked) return -1
+ if (!a.checked && b.checked) return 1
+ return 0
+ }).forEach(checkbox => {
+ this.menuTarget.appendChild(checkbox.closest('div'))
+ })
+ }
+
+ isAnyCheckboxChecked() {
+ return this.checkboxTargets.some(checkbox => checkbox.checked)
+ }
+}
diff --git a/admin/app/components/solidus_admin/ui/table/ransack_filter/component.rb b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.rb
new file mode 100644
index 00000000000..46c485b7f1f
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::UI::Table::RansackFilter::Component < SolidusAdmin::BaseComponent
+ # @param presentation [String] The label for the filter.
+ # @param search_param [String] The search parameter for the filter query.
+ # @param combinator [String] The combining logic for filter options.
+ # @param attribute [String] The database attribute the filter is based on.
+ # @param predicate [String] The comparison logic for the filter (e.g., "eq" for equals).
+ # @param options [Proc] A callable that returns filter options.
+ # @param index [Integer] The index of the filter.
+ # @param form [String] The form in which the filter resides.
+ def initialize(
+ presentation:,
+ combinator:, attribute:, predicate:, options:, form:, index:, search_param: :q
+)
+ @presentation = presentation
+ @group = "#{search_param}[g][#{index}]"
+ @combinator = build(:combinator, combinator)
+ @attribute = attribute
+ @predicate = predicate
+ @options = options
+ @form = form
+ @index = index
+ end
+
+ def before_render
+ @selections = @options.map.with_index do |(label, value), opt_index|
+ Selection.new(
+ "#{stimulus_id}--#{label}-#{value}",
+ label,
+ build(:attribute, @attribute, opt_index),
+ build(:predicate, @predicate, opt_index),
+ build(:option, value, opt_index),
+ checked?(value)
+ )
+ end
+ end
+
+ # Builds form attributes for filter options.
+ #
+ # @param type [Symbol] The type of the form attribute.
+ # @param value [String] The value of the form attribute.
+ # @param opt_index [Integer] The index of the option, if applicable.
+ # @return [FormAttribute] The built form attribute.
+ def build(type, value, opt_index = nil)
+ suffix = SUFFIXES[type] % { index: opt_index || @index }
+ Attribute.new("#{@group}#{suffix}", value)
+ end
+
+ # Determines if a given value should be checked based on the params.
+ #
+ # @param value [String] The value of the checkbox.
+ # @return [Boolean] Returns true if the checkbox should be checked, false otherwise.
+ def checked?(value)
+ conditions = params.dig(:q, :g, @index.to_s, :c)
+ conditions && conditions.values.any? { |c| c[:v]&.include?(value.to_s) }
+ end
+
+ SUFFIXES = {
+ combinator: '[m]',
+ attribute: '[c][%s][a][]',
+ predicate: '[c][%s][p]',
+ option: '[c][%s][v][]'
+ }
+
+ Selection = Struct.new(:id, :presentation, :attribute, :predicate, :option, :checked)
+ Attribute = Struct.new(:name, :value)
+end
diff --git a/admin/app/components/solidus_admin/ui/table/ransack_filter/component.yml b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.yml
new file mode 100644
index 00000000000..cac8d2c2b8f
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/table/ransack_filter/component.yml
@@ -0,0 +1,5 @@
+# Add your component translations here.
+# Use the translation in the example in your template with `t(".hello")`.
+en:
+ search: Search
+ no_filter_options: No options available
diff --git a/admin/lib/solidus_admin/configuration.rb b/admin/lib/solidus_admin/configuration.rb
index 4a745cb7d8c..cdb8fc9ad4b 100644
--- a/admin/lib/solidus_admin/configuration.rb
+++ b/admin/lib/solidus_admin/configuration.rb
@@ -75,10 +75,10 @@ class Configuration < Spree::Preferences::Configuration
# The key that specifies the attributes for searching orders within the admin interface.
# This preference controls which attributes of an order are used in search queries.
# By default, it is set to
- # 'number_shipments_number_or_bill_address_name_or_email_order_promotions_promotion_code_value_cont',
- # enabling a search across order number, shipment number, billing address name, email, and promotion code value.
+ # 'number_or_shipments_number_or_bill_address_name_or_email_cont',
+ # enabling a search across order number, shipment number, billing address name, email.
# @return [String] The search key used to determine order attributes for search.
- preference :order_search_key, :string, default: :number_or_shipments_number_or_bill_address_name_or_email_or_order_promotions_promotion_code_value_cont
+ preference :order_search_key, :string, default: :number_or_shipments_number_or_bill_address_name_or_email_cont
# @!attribute [rw] products_per_page
# @return [Integer] The number of products to display per page in the admin interface.
diff --git a/admin/spec/components/previews/solidus_admin/ui/table/ransack_filter/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/table/ransack_filter/component_preview.rb
new file mode 100644
index 00000000000..7db9b1a0d76
--- /dev/null
+++ b/admin/spec/components/previews/solidus_admin/ui/table/ransack_filter/component_preview.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# @component "ui/table/ransack_filter"
+class SolidusAdmin::UI::Table::RansackFilter::ComponentPreview < ViewComponent::Preview
+ include SolidusAdmin::Preview
+
+ def overview
+ render_with_template
+ end
+
+ # @param presentation
+ # @param search_bar select { choices: [[ Yes, 10], [ No, 3]] }
+ def playground(presentation: "Filter", search_bar: 10)
+ render current_component.new(
+ presentation: presentation,
+ combinator: 'or',
+ attribute: "attribute",
+ predicate: "eq",
+ options: Array.new(search_bar.to_i) { |o| [o, 0] },
+ index: 0,
+ form: "id"
+ )
+ end
+end
diff --git a/admin/spec/components/previews/solidus_admin/ui/table/ransack_filter/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/table/ransack_filter/component_preview/overview.html.erb
new file mode 100644
index 00000000000..98ab773a6dd
--- /dev/null
+++ b/admin/spec/components/previews/solidus_admin/ui/table/ransack_filter/component_preview/overview.html.erb
@@ -0,0 +1,21 @@
+
+
+ <% {
+ 'Colors': ['red', 'green', 'blue', 'white'],
+ 'Sizes': ["XS", "S", "M", "L", "XL", "XXL", "XXXL"],
+ }.each do |presentation, options| %>
+ <%= presentation %> |
+
+ <%= render current_component.new(
+ presentation: presentation,
+ combinator: 'or',
+ attribute: "attribute",
+ predicate: "eq",
+ options: options.map {|o| [o, 0]},
+ index: 0,
+ form: "id"
+ ) %>
+ |
+ <% end %>
+
+
diff --git a/admin/spec/components/solidus_admin/ui/table/ransack_filter/component_spec.rb b/admin/spec/components/solidus_admin/ui/table/ransack_filter/component_spec.rb
new file mode 100644
index 00000000000..56b2de840c7
--- /dev/null
+++ b/admin/spec/components/solidus_admin/ui/table/ransack_filter/component_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SolidusAdmin::UI::Table::RansackFilter::Component, type: :component do
+ it "renders the overview preview" do
+ render_preview(:overview)
+ end
+end
|