diff --git a/app/assets/stylesheets/madmin/application.css b/app/assets/stylesheets/madmin/application.css index 93192a92..63a05824 100644 --- a/app/assets/stylesheets/madmin/application.css +++ b/app/assets/stylesheets/madmin/application.css @@ -7,4 +7,5 @@ @import url("/madmin/forms.css"); @import url("/madmin/tables.css"); @import url("/madmin/pagination.css"); - +@import url("/madmin/dropdown.css"); +@import url("/madmin/filter.css"); diff --git a/app/assets/stylesheets/madmin/buttons.css b/app/assets/stylesheets/madmin/buttons.css index c22c2f6b..01be9f82 100644 --- a/app/assets/stylesheets/madmin/buttons.css +++ b/app/assets/stylesheets/madmin/buttons.css @@ -3,6 +3,7 @@ display: inline-block; font-weight: 600; text-decoration: none; + cursor: pointer; font-size: 0.875rem; line-height: 1.25rem; diff --git a/app/assets/stylesheets/madmin/dropdown.css b/app/assets/stylesheets/madmin/dropdown.css new file mode 100644 index 00000000..f046b84d --- /dev/null +++ b/app/assets/stylesheets/madmin/dropdown.css @@ -0,0 +1,23 @@ +.dropdown { + position: relative; + display: inline-block; + + & .dropdown-content { + background-color: var(--background-color); + border-radius: 0.375rem; + border: 1px solid var(--border-color); + box-shadow: 0 20px 25px -5px var(--border-color), 0 8px 10px -6px var(--border-color); + padding: 0.5rem; + position: absolute; + visibility: hidden; + opacity: 0; + z-index: 10; + right: 0; + } + + &:focus .dropdown-content, + &:focus-within .dropdown-content { + visibility: visible; + opacity: 1; + } +} diff --git a/app/assets/stylesheets/madmin/filter.css b/app/assets/stylesheets/madmin/filter.css new file mode 100644 index 00000000..a03fdcbe --- /dev/null +++ b/app/assets/stylesheets/madmin/filter.css @@ -0,0 +1,90 @@ +#filter-form { + position: relative; + display: inline-block; + + & .dropdown { + right: 0; + top: 100%; + + & .dropdown-content { + right: -100%; + } + + @media (min-width: 768px) { + & .dropdown-content { + min-width: 32rem; + right: 0; + } + } + } + + & .filter-actions { + display: flex; + justify-content: space-between; + gap: 1rem; + } + + & .filter-group { + display: grid; + gap: 1rem; + background: var(--background-color); + padding-bottom: 1rem; + } + + & .filter-group-header { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + gap: 1rem; + & h2 { + font-size: 1.125rem; + font-weight: 500; + margin-top: 0px; + } + } + + & .conditions-container { + display: grid; + gap: 0.75rem; + } + + & .filter-condition { + display: grid; + gap: 0.5rem; + align-items: start; + + @media (min-width: 768px) { + grid-template-columns: 1fr 1fr 1fr auto; + align-items: center; + } + + & .remove-condition { + padding: 0.25rem; + min-width: 2rem; + } + } + + & .add-condition { + display: flex; + gap: 0.5rem; + align-items: center; + justify-self: start; + } + + & .remove-group { + justify-self: end; + } + + svg { + width: 1.25rem; + height: 1.25rem; + } + + .hidden { + display: none; + } + + .select-value { + width: 180px; + } +} diff --git a/app/controllers/madmin/resource_controller.rb b/app/controllers/madmin/resource_controller.rb index e0189c83..cbeb450a 100644 --- a/app/controllers/madmin/resource_controller.rb +++ b/app/controllers/madmin/resource_controller.rb @@ -70,7 +70,7 @@ def resource_name def scoped_resources resources = resource.model.send(valid_scope) - resources = Madmin::Search.new(resources, resource, search_term).run + resources = Madmin::Search.new(resources, resource, search_term, filters_params).run resources.reorder(sort_column => sort_direction) end @@ -104,5 +104,9 @@ def change_polymorphic(data) def search_term @search_term ||= params[:q].to_s.strip end + + def filters_params + @filters_params ||= params.fetch(:filters, {}).permit!.to_h + end end end diff --git a/app/helpers/madmin/sort_helper.rb b/app/helpers/madmin/sort_helper.rb index 9df3e5a1..d06ea23e 100644 --- a/app/helpers/madmin/sort_helper.rb +++ b/app/helpers/madmin/sort_helper.rb @@ -3,8 +3,9 @@ module SortHelper def sortable(column, title, options = {}) matching_column = (column.to_s == sort_column) direction = (sort_direction == "asc") ? "desc" : "asc" + filters = params.fetch(:filters, {}).permit!.to_h - link_to resource.index_path(sort: column, direction: direction), options do + link_to resource.index_path(sort: column, direction: direction, filters: filters), options do concat title if matching_column concat " " diff --git a/app/javascript/madmin/controllers/filter_controller.js b/app/javascript/madmin/controllers/filter_controller.js new file mode 100644 index 00000000..04333de1 --- /dev/null +++ b/app/javascript/madmin/controllers/filter_controller.js @@ -0,0 +1,137 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["conditionGroups", "filterButton"] + static values = { + initialFilters: String + } + + connect() { + this.constructForm() + // Only add initial group if none exist + if (this.conditionGroupsTarget.children.length === 0) { + this.addNewGroupWithCondition() + } + } + + disconnect() { + // clear filters to prevent caching + this.conditionGroupsTarget.innerHTML = '' + } + + constructForm() { + // Reconstruct filters from params + const filters = JSON.parse(this.initialFiltersValue) + if (!filters.groups) return + + Object.entries(filters.groups).forEach(([groupId, group]) => { + let groupElement = this.addConditionGroup(groupId) + groupElement.querySelector('.select-match-type').value = group.match_type + + if (!group.conditions) return + Object.entries(group.conditions).forEach(([conditionId, condition]) => { + let el = this.addCondition({ params: { groupId } }) + const columnSelect = el.querySelector('.select-column') + columnSelect.value = condition.column + + // Trigger the column change to show the correct input + this.handleColumnChange({ target: columnSelect }) + + el.querySelector('.select-operator').value = condition.operator + el.querySelector('.select-value:not(.hidden)').value = condition.value + }) + }) + } + + // Actions + addNewGroupWithCondition() { + const groupId = this.#generateUniqueId() + this.addConditionGroup(groupId) + this.addCondition({ params: { groupId } }) + } + + addCondition({ params: { groupId } }) { + if (!groupId) return + + const conditionsContainer = document.getElementById(`conditions-${groupId}`) + if (!conditionsContainer) return + + const conditionId = this.#generateUniqueId() + const newCondition = this.#buildCondition(groupId, conditionId) + conditionsContainer.appendChild(newCondition) + return newCondition + } + + removeCondition({ event, params: { groupId, conditionId } }) { + const condition = document.getElementById(`condition-${groupId}-${conditionId}`) + condition?.remove() + this.filterButtonTarget.focus() + } + + addConditionGroup(groupId) { + const newGroup = this.#buildGroup(groupId) + this.conditionGroupsTarget.appendChild(newGroup) + return newGroup + } + + removeConditionGroup({ params: { groupId } }) { + const group = document.getElementById(`group-${groupId}`) + group?.remove() + this.filterButtonTarget.focus() + } + + handleColumnChange(event) { + const column = event.target + const conditionElement = column.closest('.filter-condition') + const filterType = column.selectedOptions[0].dataset.filterType + const inputTypeClass = `input-type-${filterType || 'text'}` + const input = conditionElement.querySelector(`.${inputTypeClass}`) || conditionElement.querySelector('.input-type-text') + + conditionElement.querySelectorAll('.select-value').forEach(input => { + input.classList.add('hidden') + input.disabled = true + input.value = '' + }) + + // Update hidden input to store the type + const typeInput = conditionElement.querySelector('.select-type') + typeInput.value = filterType + + // Enable the correct input + input.classList.remove('hidden') + input.disabled = false + + // Set default value for boolean type + if (filterType === 'boolean') { + input.value = 'true' + } + } + + // Private Methods + + #buildGroup(groupId) { + const template = document.getElementById('condition-group-template') + const content = template.content.cloneNode(true) + return this.#replaceIds(content.firstElementChild, 'GROUP_ID', groupId) + } + + #buildCondition(groupId, conditionId) { + const template = document.getElementById('condition-template') + const content = template.content.cloneNode(true) + const element = content.firstElementChild + + this.#replaceIds(element, 'GROUP_ID', groupId) + return this.#replaceIds(element, 'CONDITION_ID', conditionId) + } + + #replaceIds(element, placeholder, id) { + const regex = new RegExp(placeholder, 'g') + element.id = element.id.replace(regex, id) + element.innerHTML = element.innerHTML.replace(regex, id) + return element + } + + #generateUniqueId() { + return crypto?.randomUUID?.() || Math.random().toString(36).slice(2) + } +} diff --git a/app/javascript/madmin/controllers/index.js b/app/javascript/madmin/controllers/index.js index 02977bf0..6cd3f0a6 100644 --- a/app/javascript/madmin/controllers/index.js +++ b/app/javascript/madmin/controllers/index.js @@ -2,4 +2,4 @@ import { application } from "controllers/application" // Eager load all controllers defined in the import map under controllers/**/*_controller import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" -eagerLoadControllersFrom("controllers", application) +eagerLoadControllersFrom("controllers", application) \ No newline at end of file diff --git a/app/views/madmin/application/index.html.erb b/app/views/madmin/application/index.html.erb index a4dac6e1..9503d447 100644 --- a/app/views/madmin/application/index.html.erb +++ b/app/views/madmin/application/index.html.erb @@ -15,6 +15,8 @@ <% end if params[:q].present? %> + <%= render "madmin/shared/filters/filter_form" %> + <%= link_to "New #{resource.friendly_name}", resource.new_path, class: "btn btn-secondary" %> diff --git a/app/views/madmin/shared/filters/_condition_groups.html.erb b/app/views/madmin/shared/filters/_condition_groups.html.erb new file mode 100644 index 00000000..503ef219 --- /dev/null +++ b/app/views/madmin/shared/filters/_condition_groups.html.erb @@ -0,0 +1,115 @@ + + + \ No newline at end of file diff --git a/app/views/madmin/shared/filters/_filter_form.html.erb b/app/views/madmin/shared/filters/_filter_form.html.erb new file mode 100644 index 00000000..c8f65164 --- /dev/null +++ b/app/views/madmin/shared/filters/_filter_form.html.erb @@ -0,0 +1,27 @@ +
+ +
\ No newline at end of file diff --git a/lib/madmin/search.rb b/lib/madmin/search.rb index d85e8a46..6eb288de 100644 --- a/lib/madmin/search.rb +++ b/lib/madmin/search.rb @@ -2,31 +2,55 @@ module Madmin class Search - attr_reader :query + FILTER_OPERATORS = { + # Equality + "eq" => "=", + "not_eq" => "!=", - def initialize(scoped_resource, resource, term) + # Comparison + "lt" => "<", + "lte" => "<=", + "gt" => ">", + "gte" => ">=", + + # Text patterns + "starts_with" => "LIKE", + "ends_with" => "LIKE", + "contains" => "LIKE", + "not_contains" => "NOT LIKE", + + # Specials + "is_null" => "IS NULL", + "is_not_null" => "IS NOT NULL", + "in" => "IN", + "not_in" => "NOT IN" + }.freeze + + attr_reader :query, :filters + + def initialize(scoped_resource, resource, term, filters) @resource = resource @scoped_resource = scoped_resource @query = term + @filters = filters end def run - if query.blank? - @scoped_resource.all - else - search_results(@scoped_resource) - end + scope = @scoped_resource + scope = apply_search(scope) if query.present? + scope = apply_filters(scope) if filters.present? + scope end private - def search_results(resources) - resources.where(query_template, *query_values) + def apply_search(scope) + scope.where(query_template, *query_values) end def query_template search_attributes.map do |attr| - table_name = query_table_name(attr) + table_name = query_table_name searchable_fields(attr).map do |field| column_name = column_to_query(field) "LOWER(CAST(#{table_name}.#{column_name} AS CHAR(256))) LIKE ?" @@ -49,7 +73,73 @@ def search_attributes @resource.searchable_attributes end - def query_table_name(attr) + def apply_filters(scope) + groups = filters[:groups].to_h + + groups.each do |group_id, group_data| + conditions = group_data["conditions"] || {} + match_type = group_data["match_type"] || "all" + + next if conditions.empty? + + group_conditions = conditions.map do |condition_id, condition| + column = condition["column"] + operator = condition["operator"] + value = condition["value"] + type = condition["type"] + next unless column.in?(filterable_columns) && FILTER_OPERATORS.key?(operator) + + apply_single_filter(scope, column, operator, value, type) + end.compact + + next if group_conditions.empty? + + group_scope = if match_type == "all" + # AND all conditions together + group_conditions.reduce { |result, condition| result.merge(condition) } + else + # OR all conditions together + group_conditions.reduce { |result, condition| result.or(condition) } + end + + scope = scope.merge(group_scope) + end + + scope + end + + def apply_single_filter(scope, column, operator, value, type) + sql_operator = FILTER_OPERATORS[operator] + table_name = query_table_name + + # Cast types if necessary + if type == "boolean" + value = value == "true" + end + + case operator + when "like", "not_like" + scope.where("#{table_name}.#{column} #{sql_operator} ?", "%#{value}%") + when "starts_with" + scope.where("#{table_name}.#{column} #{sql_operator} ?", "#{value}%") + when "ends_with" + scope.where("#{table_name}.#{column} #{sql_operator} ?", "%#{value}") + when "contains", "not_contains" + scope.where("#{table_name}.#{column} #{sql_operator} ?", "%#{value}%") + when "is_null", "is_not_null" + scope.where("#{table_name}.#{column} #{sql_operator}") + when "in", "not_in" + scope.where("#{table_name}.#{column} #{sql_operator} (?)", Array(value)) + else + scope.where("#{table_name}.#{column} #{sql_operator} ?", value) + end + end + + def filterable_columns + @resource.sortable_columns + end + + def query_table_name ::ActiveRecord::Base.connection.quote_column_name(@scoped_resource.table_name) end diff --git a/test/madmin/search_test.rb b/test/madmin/search_test.rb index f02d852e..5364e3ca 100644 --- a/test/madmin/search_test.rb +++ b/test/madmin/search_test.rb @@ -2,11 +2,11 @@ class SearchTest < ActiveSupport::TestCase test "generates query" do - results = Madmin::Search.new(User.all, UserResource, "chris").run + results = Madmin::Search.new(User.all, UserResource, "chris", {}).run assert_equal users(:one), results.first end test "returns empty relation when no results found" do - assert_empty Madmin::Search.new(User.all, UserResource, "nothing").run + assert_empty Madmin::Search.new(User.all, UserResource, "nothing", {}).run end end