Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Admin] Add dynamic filters to ui/table component #5376

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Introduce dynamic ransack filter component for complex queries
Introduces a new Ransack filter component to dynamically generate advanced,
grouped query parameters.

Suppose you have a dropdown for: 'State', 'Variants', and 'Promotions',
And checkboxes for: 'state_eq', 'line_items_variant_id' and 'promotions_id'.

The component will dynamically create the following query parameters:
q[g][0][c][0][a][]: state
q[g][0][c][0][p]: eq
q[g][0][c][0][v][]: checkout
...
q[g][1][c][31][a][]: line_items_variant_id
q[g][1][c][31][p]: in
q[g][1][c][31][v][]: 2
...
q[g][2][c][42][a][]: promotions_id
q[g][2][c][42][p]: in
q[g][2][c][42][v][]: 8

This provides greater flexibility when configuring filters for the table component.

Usage in table component:

The `filters` method within the table component can be used to define the
attributes, predicates, and options for the dynamic filter.
This makes it convenient to set up intricate filters directly from the table
component's configuration.

For instance:
```
<%= render component('ui/table').new(
    ...
    filters: [
      {
        presentation: t('.filters.status'),
        combinator: 'or',
        attribute: "state",
        predicate: "eq",
        options: Spree::Order.state_machines[:state].states.map { |state| [state.value.titleize, state.value] }
      }
    ]
    ...
  ) %>
```
  • Loading branch information
elia committed Oct 10, 2023
commit 53e5b45864fda5d421e1ae5886d29a5f0be4c646
15 changes: 3 additions & 12 deletions admin/app/components/solidus_admin/ui/table/component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"
data-controller="<%= stimulus_id %>"
data-<%= stimulus_id %>-selected-row-class="bg-gray-15"
data-action="<%= component("ui/table/ransack_filter").stimulus_id %>:search-><%= stimulus_id %>#search"
>
<% toolbar_classes = "
h-14 p-2 bg-white border-b border-gray-100
Expand Down Expand Up @@ -59,18 +60,8 @@

<% if @filters.any? %>
<div class="<%= toolbar_classes %>" data-<%= stimulus_id %>-target="filterToolbar">
<div class="font-semibold text-gray-700 text-sm px-2"><%= t('.refine_search') %>:</div>
<% @filters.each do |filter| %>
<label class="flex gap-2 px-2">
<%= render component('ui/forms/checkbox').new(
name: filter[:name],
value: filter[:value],
size: :s,
form: search_form_id,
'data-action': "#{stimulus_id}#search",
) %>
<span class="text-gray-700 leading-none text-sm self-center"><%= filter[:label] %></span>
</label>
<% @filters.each_with_index do |filter, index| %>
<%= render_ransack_filter_dropdown(filter, index) %>
<% end %>
</div>
<% end %>
Expand Down
28 changes: 22 additions & 6 deletions admin/app/components/solidus_admin/ui/table/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,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<Hash>] 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<Hash>] 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<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.
Expand Down Expand Up @@ -113,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)
Expand Down Expand Up @@ -145,6 +161,6 @@ def render_data_cell(cell, data)

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<div class="<%= stimulus_id %>" data-controller="<%= stimulus_id %>">
<input type="hidden" form="<%= @form %>"
name="<%= @combinator.name %>"
value="<%= @combinator.value %>">

<details class="relative inline-block text-left" data-<%= stimulus_id %>-target="details">
<summary class="
inline-flex justify-center
rounded-full border border-gray-300
shadow-sm
px-3 py-2
text-sm font-medium text-gray-700
hover:bg-gray-50
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500
[&::marker]:hidden
[&::-webkit-details-marker]:hidden
cursor-default
" data-<%= stimulus_id %>-target="summary">
<%= @presentation %>
<%= render component("ui/icon").new(name: 'arrow-down-s-fill', class: "w-[1.4em] h-[1.4em]") %>
</summary>

<div class="
absolute
left-0 mt-2 w-56
rounded-md shadow-lg
bg-white
ring-1 ring-black ring-opacity-5
">
<div class="relative">
<div class="py-1 max-h-[240px] overflow-y-auto" role="menu" aria-orientation="vertical" aria-labelledby="options-menu" data-<%= stimulus_id %>-target="menu">
<% @selections.each do |selection| %>
<div class="px-4 py-2" data-<%= stimulus_id %>-target="option">
<input type="hidden" form="<%= @form %>"
name="<%= selection.attribute.name %>"
value="<%= selection.attribute.value %>">
<input type="hidden" form="<%= @form %>"
name="<%= selection.predicate.name %>"
value="<%= selection.predicate.value %>">

<%= render component('ui/forms/checkbox').new(
name: selection.option.name,
value: selection.option.value,
size: :s,
form: @form,
"data-action": "#{stimulus_id}#search",
"data-#{stimulus_id}-target": "checkbox"
) %>

<%= label_tag nil, selection.presentation, class: "ml-2 text-sm text-gray-700" %>
</div>
<% end %>
</div>
</div>
</div>
</details>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['details', 'summary', 'option', 'checkbox', 'menu']

search() {
this.dispatch('search')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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(
label,
build(:attribute, @attribute, opt_index),
build(:predicate, @predicate, opt_index),
build(:option, value, opt_index)
)
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

SUFFIXES = {
combinator: '[m]',
attribute: '[c][%<index>s][a][]',
predicate: '[c][%<index>s][p]',
option: '[c][%<index>s][v][]'
}

Selection = Struct.new(:presentation, :attribute, :predicate, :option, :checked)
Attribute = Struct.new(:name, :value)
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Add your component translations here.
# Use the translation in the example in your template with `t(".hello")`.
en:
search: Search
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<table>
<tr>
<% {
'Colors': ['red', 'green', 'blue', 'white'],
'Sizes': ["XS", "S", "M", "L", "XL", "XXL", "XXXL"],
}.each do |presentation, options| %>
<td class="font-bold px-3 py-1"><%= presentation %></td>
<td class="px-3 py-1 text-center">
<%= render current_component.new(
presentation: presentation,
combinator: 'or',
attribute: "attribute",
predicate: "eq",
options: options.map {|o| [o, 0]},
index: 0,
form: "id"
) %>
</td>
<% end %>
</tr>
</table>
Original file line number Diff line number Diff line change
@@ -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