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 @@
+
+ Filter Group
+
+
+
+
+