madmin 2.5.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +121 -4
- data/app/assets/stylesheets/madmin/actiontext.css +31 -0
- data/app/assets/stylesheets/madmin/base.css +22 -11
- data/app/assets/stylesheets/madmin/buttons.css +9 -9
- data/app/assets/stylesheets/madmin/forms.css +263 -7
- data/app/assets/stylesheets/madmin/pagination.css +10 -23
- data/app/assets/stylesheets/madmin/reset.css +1 -1
- data/app/assets/stylesheets/madmin/sidebar.css +6 -6
- data/app/assets/stylesheets/madmin/tables.css +5 -1
- data/app/controllers/madmin/base_controller.rb +1 -5
- data/app/controllers/madmin/resource_controller.rb +49 -16
- data/app/helpers/madmin/application_helper.rb +15 -2
- data/app/helpers/madmin/filters_helper.rb +26 -0
- data/app/helpers/madmin/sort_helper.rb +2 -2
- data/app/javascript/madmin/controllers/filters_controller.js +35 -0
- data/app/javascript/madmin/controllers/nested_form_controller.js +5 -0
- data/app/javascript/madmin/controllers/select_controller.js +269 -22
- data/app/views/layouts/madmin/application.html.erb +1 -0
- data/app/views/madmin/application/_active_filters.html.erb +15 -0
- data/app/views/madmin/application/_filter.html.erb +13 -0
- data/app/views/madmin/application/_filters.html.erb +32 -0
- data/app/views/madmin/application/_javascript.html.erb +0 -2
- data/app/views/madmin/application/_pagination.html.erb +32 -0
- data/app/views/madmin/application/edit.html.erb +1 -1
- data/app/views/madmin/application/index.html.erb +18 -15
- data/app/views/madmin/application/new.html.erb +1 -1
- data/app/views/madmin/application/show.html.erb +5 -3
- data/app/views/madmin/fields/array/_form.html.erb +1 -0
- data/app/views/madmin/fields/array/_index.html.erb +1 -0
- data/app/views/madmin/fields/array/_show.html.erb +1 -0
- data/app/views/madmin/fields/attachment/_form.html.erb +0 -4
- data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
- data/app/views/madmin/fields/attachments/_index.html.erb +1 -1
- data/app/views/madmin/fields/belongs_to/_form.html.erb +1 -1
- data/app/views/madmin/fields/belongs_to/_index.html.erb +5 -2
- data/app/views/madmin/fields/belongs_to/_show.html.erb +1 -1
- data/app/views/madmin/fields/enum/_form.html.erb +1 -1
- data/app/views/madmin/fields/has_many/_form.html.erb +1 -1
- data/app/views/madmin/fields/has_many/_index.html.erb +1 -1
- data/app/views/madmin/fields/has_many/_show.html.erb +3 -11
- data/app/views/madmin/fields/has_one/_index.html.erb +5 -1
- data/app/views/madmin/fields/has_one/_show.html.erb +1 -1
- data/app/views/madmin/fields/json/_form.html.erb +1 -1
- data/app/views/madmin/fields/json/_show.html.erb +1 -1
- data/app/views/madmin/fields/nested_has_many/_index.html.erb +1 -1
- data/app/views/madmin/fields/nested_has_many/_show.html.erb +3 -11
- data/app/views/madmin/fields/nested_has_one/_form.html.erb +18 -0
- data/app/views/madmin/fields/polymorphic/_form.html.erb +1 -2
- data/app/views/madmin/fields/polymorphic/_index.html.erb +5 -1
- data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
- data/app/views/madmin/fields/select/_form.html.erb +1 -1
- data/config/importmap.rb +0 -1
- data/config/locales/el.yml +86 -0
- data/config/locales/en.yml +46 -0
- data/config/locales/fr.yml +46 -0
- data/config/locales/ko.yml +84 -0
- data/lib/madmin/field.rb +27 -1
- data/lib/madmin/fields/array.rb +16 -0
- data/lib/madmin/fields/attachment.rb +7 -0
- data/lib/madmin/fields/belongs_to.rb +2 -1
- data/lib/madmin/fields/boolean.rb +1 -0
- data/lib/madmin/fields/currency.rb +7 -0
- data/lib/madmin/fields/date.rb +1 -0
- data/lib/madmin/fields/date_time.rb +1 -0
- data/lib/madmin/fields/decimal.rb +1 -0
- data/lib/madmin/fields/float.rb +1 -0
- data/lib/madmin/fields/has_many.rb +6 -23
- data/lib/madmin/fields/integer.rb +1 -0
- data/lib/madmin/fields/json.rb +21 -0
- data/lib/madmin/fields/nested_has_many.rb +2 -2
- data/lib/madmin/fields/nested_has_one.rb +48 -0
- data/lib/madmin/fields/polymorphic.rb +5 -0
- data/lib/madmin/fields/string.rb +3 -1
- data/lib/madmin/fields/text.rb +3 -1
- data/lib/madmin/filter.rb +96 -0
- data/lib/madmin/menu.rb +2 -1
- data/lib/madmin/page.rb +61 -0
- data/lib/madmin/pagination.rb +50 -0
- data/lib/madmin/resource.rb +95 -7
- data/lib/madmin/resource_builder.rb +8 -0
- data/lib/madmin/search.rb +5 -7
- data/lib/madmin/version.rb +1 -1
- data/lib/madmin.rb +47 -2
- metadata +19 -16
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
margin-bottom: 0.1rem;
|
|
29
29
|
|
|
30
30
|
&:hover {
|
|
31
|
-
background-color:
|
|
31
|
+
background-color: var(--hover-color);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
&.active {
|
|
35
|
-
background-color:
|
|
35
|
+
background-color: var(--hover-color);
|
|
36
36
|
font-weight: 600;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -90,11 +90,11 @@ main {
|
|
|
90
90
|
margin-bottom: 0.1rem;
|
|
91
91
|
|
|
92
92
|
&:hover {
|
|
93
|
-
background-color:
|
|
93
|
+
background-color: var(--hover-color);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
&.active {
|
|
97
|
-
background-color:
|
|
97
|
+
background-color: var(--hover-color);
|
|
98
98
|
font-weight: 600;
|
|
99
99
|
}
|
|
100
100
|
}
|
|
@@ -106,7 +106,7 @@ main {
|
|
|
106
106
|
margin-top: 1rem;
|
|
107
107
|
|
|
108
108
|
a {
|
|
109
|
-
color:
|
|
109
|
+
color: var(--light-text-color);
|
|
110
110
|
display: flex;
|
|
111
111
|
align-items: center;
|
|
112
112
|
gap: 0.25rem;
|
|
@@ -119,7 +119,7 @@ main {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
&:hover {
|
|
122
|
-
color: var(--
|
|
122
|
+
color: var(--link-color);
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
}
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
position: relative;
|
|
6
6
|
min-width: 0;
|
|
7
7
|
width: 100%;
|
|
8
|
+
|
|
9
|
+
&:not(:has(tbody tr)) th {
|
|
10
|
+
border-bottom: none;
|
|
11
|
+
}
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
table {
|
|
@@ -17,7 +21,7 @@ table {
|
|
|
17
21
|
text-align: left;
|
|
18
22
|
|
|
19
23
|
a {
|
|
20
|
-
color:
|
|
24
|
+
color: var(--text-color);
|
|
21
25
|
text-decoration: none;
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -2,11 +2,7 @@ module Madmin
|
|
|
2
2
|
class BaseController < ActionController::Base
|
|
3
3
|
include ::ActiveStorage::SetCurrent if defined?(::ActiveStorage)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
include Pagy::Method
|
|
7
|
-
else
|
|
8
|
-
include Pagy::Backend
|
|
9
|
-
end
|
|
5
|
+
include Madmin::Pagination
|
|
10
6
|
|
|
11
7
|
protect_from_forgery with: :exception
|
|
12
8
|
|
|
@@ -3,12 +3,13 @@ module Madmin
|
|
|
3
3
|
include SortHelper
|
|
4
4
|
|
|
5
5
|
before_action :set_record, except: [:index, :new, :create]
|
|
6
|
+
before_action :enforce_readonly, only: [:new, :create, :edit, :update, :destroy]
|
|
6
7
|
|
|
7
8
|
# Assign current_user for paper_trail gem
|
|
8
9
|
before_action :set_paper_trail_whodunnit, if: -> { respond_to?(:set_paper_trail_whodunnit, true) }
|
|
9
10
|
|
|
10
11
|
def index
|
|
11
|
-
@
|
|
12
|
+
@page, @records = paginate_collection(scoped_resources)
|
|
12
13
|
|
|
13
14
|
respond_to do |format|
|
|
14
15
|
format.html
|
|
@@ -26,8 +27,8 @@ module Madmin
|
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def create
|
|
29
|
-
@record = resource.model.new
|
|
30
|
-
if
|
|
30
|
+
@record = resource.model.new
|
|
31
|
+
if save_record(resource_params)
|
|
31
32
|
redirect_to resource.show_path(@record)
|
|
32
33
|
else
|
|
33
34
|
render :new, status: :unprocessable_entity
|
|
@@ -38,7 +39,7 @@ module Madmin
|
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def update
|
|
41
|
-
if
|
|
42
|
+
if save_record(resource_params)
|
|
42
43
|
redirect_to resource.show_path(@record)
|
|
43
44
|
else
|
|
44
45
|
render :edit, status: :unprocessable_entity
|
|
@@ -67,41 +68,73 @@ module Madmin
|
|
|
67
68
|
|
|
68
69
|
def scoped_resources
|
|
69
70
|
resources = resource.model.send(valid_scope)
|
|
70
|
-
resources = Madmin::Search.new(resources, resource, search_term).run
|
|
71
|
+
resources = Madmin::Search.new(resources, resource, search_term, filters).run
|
|
72
|
+
|
|
73
|
+
return resources if sort_column.blank?
|
|
74
|
+
|
|
71
75
|
resources.reorder(sort_column => sort_direction)
|
|
72
76
|
end
|
|
73
77
|
|
|
78
|
+
# Returns [page, records]. Override for non-ActiveRecord collections and
|
|
79
|
+
# return an object responding to the Madmin::Page interface.
|
|
80
|
+
def paginate_collection(collection)
|
|
81
|
+
paginate(collection, page: params[:page])
|
|
82
|
+
end
|
|
83
|
+
|
|
74
84
|
def valid_scope
|
|
75
85
|
scope = params.fetch(:scope, "all")
|
|
76
86
|
resource.scopes.include?(scope.to_sym) ? scope : :all
|
|
77
87
|
end
|
|
78
88
|
|
|
79
89
|
def resource_params
|
|
80
|
-
params.require(resource.param_key)
|
|
81
|
-
.permit(*resource.permitted_params)
|
|
82
|
-
.transform_values { |v| change_polymorphic(v) }
|
|
90
|
+
cast_fields params.require(resource.param_key).permit(*resource.permitted_params)
|
|
83
91
|
end
|
|
84
92
|
|
|
85
93
|
def new_resource_params
|
|
86
|
-
params.fetch(resource.param_key, {}).permit
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
cast_fields params.fetch(resource.param_key, {}).permit!.permit(*resource.permitted_params)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Lets each field convert its submitted value, such as parsing JSON
|
|
98
|
+
def cast_fields(attributes)
|
|
99
|
+
attributes.to_h.to_h do |name, value|
|
|
100
|
+
field = field_for(name)
|
|
101
|
+
[name, field ? field.cast(value) : value]
|
|
102
|
+
end
|
|
89
103
|
end
|
|
90
104
|
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
# Assigns the attributes and saves, unless a field doesn't accept its value
|
|
106
|
+
def save_record(attributes)
|
|
107
|
+
@record.assign_attributes(attributes)
|
|
93
108
|
|
|
94
|
-
|
|
95
|
-
|
|
109
|
+
rejected = attributes.select { |name, value| field_for(name)&.accepts?(value) == false }.keys
|
|
110
|
+
|
|
111
|
+
if rejected.any?
|
|
112
|
+
@record.validate
|
|
113
|
+
rejected.each { |name| @record.errors.add(name, :invalid) }
|
|
114
|
+
false
|
|
96
115
|
else
|
|
97
|
-
|
|
116
|
+
@record.save
|
|
98
117
|
end
|
|
99
118
|
end
|
|
100
119
|
|
|
120
|
+
def field_for(name)
|
|
121
|
+
resource.get_attribute(name.to_sym)&.field
|
|
122
|
+
end
|
|
123
|
+
|
|
101
124
|
def search_term
|
|
102
125
|
@search_term ||= params[:q].to_s.strip
|
|
103
126
|
end
|
|
104
127
|
|
|
128
|
+
# The index filters from `filters[][column]`, `filters[][operator]` and `filters[][value]`
|
|
129
|
+
def filters
|
|
130
|
+
@filters ||= Madmin::Filter.from_params(resource, params.slice(:filters).permit(filters: [:column, :operator, :value]).fetch(:filters, []))
|
|
131
|
+
end
|
|
132
|
+
helper_method :filters
|
|
133
|
+
|
|
134
|
+
def enforce_readonly
|
|
135
|
+
redirect_to resource.index_path, alert: t("madmin.flash.readonly", name: resource.friendly_name) if resource.readonly?
|
|
136
|
+
end
|
|
137
|
+
|
|
105
138
|
ActiveSupport.run_load_hooks(:madmin_resource_controller, self)
|
|
106
139
|
end
|
|
107
140
|
end
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
module Madmin
|
|
2
2
|
module ApplicationHelper
|
|
3
|
-
include Pagy::Frontend if defined? Pagy::Frontend
|
|
4
3
|
include Rails.application.routes.url_helpers
|
|
5
4
|
|
|
5
|
+
# Links to the record's page in its resource. Renders the name without a
|
|
6
|
+
# link when the resource's show route isn't drawn.
|
|
7
|
+
def link_to_record(resource, record, **options)
|
|
8
|
+
link_to resource.display_name(record), resource.show_path(record), **options
|
|
9
|
+
rescue Madmin::MissingRoute
|
|
10
|
+
resource.display_name(record)
|
|
11
|
+
end
|
|
12
|
+
|
|
6
13
|
def clear_search_params
|
|
7
|
-
|
|
14
|
+
index_path_with(q: nil)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The current index URL with some params changed, keeping the search,
|
|
18
|
+
# scope, sort and filters. Any change goes back to the first page
|
|
19
|
+
def index_path_with(**changes)
|
|
20
|
+
resource.index_path(request.query_parameters.symbolize_keys.except(:page).merge(changes))
|
|
8
21
|
end
|
|
9
22
|
end
|
|
10
23
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Madmin
|
|
2
|
+
module FiltersHelper
|
|
3
|
+
FILTER_INPUT_TYPES = {string: "text", number: "number", date: "date", datetime: "datetime-local", boolean: "text", array: "text"}.freeze
|
|
4
|
+
|
|
5
|
+
# Operators that don't take a value are marked so the filters controller can hide the value input
|
|
6
|
+
def filter_operator_options(type)
|
|
7
|
+
Filter::OPERATORS.fetch(type).map do |operator|
|
|
8
|
+
option = [t("madmin.filters.operators.#{type}.#{operator}"), operator]
|
|
9
|
+
Filter::WITHOUT_VALUE.include?(operator) ? option << {data: {without_value: true}} : option
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def filter_column_options(resource)
|
|
14
|
+
resource.filterable_attributes.map do |attribute|
|
|
15
|
+
type = attribute.field.filter_type
|
|
16
|
+
[attribute.field.label, attribute.name, {data: {input_type: filter_input_type(type), filter_type: type}}]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def filter_input_type(type) = FILTER_INPUT_TYPES.fetch(type)
|
|
21
|
+
|
|
22
|
+
def filter_value_label(filter)
|
|
23
|
+
filter.type.in?([:date, :datetime]) ? l(filter.typed_value, format: :long) : filter.value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -4,7 +4,7 @@ module Madmin
|
|
|
4
4
|
matching_column = (column.to_s == sort_column)
|
|
5
5
|
direction = (sort_direction == "asc") ? "desc" : "asc"
|
|
6
6
|
|
|
7
|
-
link_to
|
|
7
|
+
link_to index_path_with(sort: column, direction: direction), options do
|
|
8
8
|
concat title
|
|
9
9
|
if matching_column
|
|
10
10
|
concat " "
|
|
@@ -22,7 +22,7 @@ module Madmin
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def default_sort_column
|
|
25
|
-
resource.try(:default_sort_column) || (["created_at", "id", "uuid"] & resource.
|
|
25
|
+
resource.try(:default_sort_column) || (["created_at", "id", "uuid"] & resource.sortable_columns).first || resource.sortable_columns.first
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def default_sort_direction
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Adds and removes index filter rows. When the column changes, the row gets
|
|
4
|
+
// that column's operators and input type from the server-rendered markup
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["rows", "row", "operators"]
|
|
7
|
+
|
|
8
|
+
add() {
|
|
9
|
+
this.rowsTarget.append(this.rowTarget.content.cloneNode(true))
|
|
10
|
+
this.rowsTarget.lastElementChild.querySelector("select").focus()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
remove(event) {
|
|
14
|
+
event.target.closest("[data-filter-row]").remove()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
changeColumn(event) {
|
|
18
|
+
const row = event.target.closest("[data-filter-row]")
|
|
19
|
+
const column = event.target.selectedOptions[0]
|
|
20
|
+
const operator = row.querySelector("[name='filters[][operator]']")
|
|
21
|
+
const value = row.querySelector("[name='filters[][value]']")
|
|
22
|
+
const operators = this.operatorsTargets.find(template => template.dataset.filterType == column.dataset.filterType)
|
|
23
|
+
|
|
24
|
+
operator.replaceChildren(operators.content.cloneNode(true))
|
|
25
|
+
value.type = column.dataset.inputType
|
|
26
|
+
value.step = value.type == "number" ? "any" : ""
|
|
27
|
+
value.value = ""
|
|
28
|
+
this.changeOperator({ target: operator })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
changeOperator(event) {
|
|
32
|
+
const row = event.target.closest("[data-filter-row]")
|
|
33
|
+
row.querySelector("[name='filters[][value]']").hidden = "withoutValue" in event.target.selectedOptions[0].dataset
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -7,6 +7,8 @@ export default class extends Controller {
|
|
|
7
7
|
|
|
8
8
|
connect() {
|
|
9
9
|
this.wrapperClass = this.data.get("wrapperClass") || "nested-fields"
|
|
10
|
+
// A has_one only allows one record, so the add link hides while it has one
|
|
11
|
+
this.single = this.data.get("single") == "true"
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
add_association(event) {
|
|
@@ -14,6 +16,7 @@ export default class extends Controller {
|
|
|
14
16
|
|
|
15
17
|
var content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
|
|
16
18
|
this.linksTarget.insertAdjacentHTML('beforebegin', content)
|
|
19
|
+
if (this.single) this.linksTarget.hidden = true
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
remove_association(event) {
|
|
@@ -30,5 +33,7 @@ export default class extends Controller {
|
|
|
30
33
|
wrapper.querySelector("input[name*='_destroy']").value = 1
|
|
31
34
|
wrapper.style.display = 'none'
|
|
32
35
|
}
|
|
36
|
+
|
|
37
|
+
if (this.single) this.linksTarget.hidden = false
|
|
33
38
|
}
|
|
34
39
|
}
|
|
@@ -1,37 +1,284 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
-
import TomSelect from "tom-select"
|
|
3
2
|
|
|
3
|
+
let count = 0
|
|
4
|
+
|
|
5
|
+
// Turns a <select> into a searchable combobox. The <select> stays in the form,
|
|
6
|
+
// hidden, and is what gets submitted.
|
|
7
|
+
//
|
|
8
|
+
// Without a url, the <select>'s own options are filtered as you type.
|
|
9
|
+
// With a url, options are loaded from the server when it opens and as you type:
|
|
10
|
+
//
|
|
11
|
+
// <select data-controller="select" data-select-url-value="/madmin/users.json">
|
|
12
|
+
//
|
|
13
|
+
// GET /madmin/users.json => [{"id": 1, "name": "Chris Oliver"}, ...]
|
|
14
|
+
// GET /madmin/users.json?q=chris => [{"id": 1, "name": "Chris Oliver"}]
|
|
4
15
|
export default class extends Controller {
|
|
5
|
-
static values = {
|
|
6
|
-
options: Object,
|
|
7
|
-
url: String
|
|
8
|
-
}
|
|
16
|
+
static values = { url: String }
|
|
9
17
|
|
|
10
18
|
connect() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
this.multiple = this.element.multiple
|
|
20
|
+
this.results = []
|
|
21
|
+
this.activeIndex = -1
|
|
22
|
+
|
|
23
|
+
this.build()
|
|
24
|
+
this.element.hidden = true
|
|
25
|
+
this.element.after(this.wrapper)
|
|
26
|
+
this.labelFor(this.element.id)?.setAttribute("for", this.input.id)
|
|
27
|
+
this.render()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
disconnect() {
|
|
31
|
+
clearTimeout(this.timeout)
|
|
32
|
+
this.abortController?.abort()
|
|
33
|
+
this.labelFor(this.input.id)?.setAttribute("for", this.element.id)
|
|
34
|
+
this.wrapper.remove()
|
|
35
|
+
this.element.hidden = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Events
|
|
39
|
+
|
|
40
|
+
focus() {
|
|
41
|
+
if (!this.multiple) this.input.select()
|
|
42
|
+
this.search()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
typed() {
|
|
46
|
+
clearTimeout(this.timeout)
|
|
47
|
+
if (this.hasUrlValue) {
|
|
48
|
+
this.timeout = setTimeout(() => this.search(), 300)
|
|
49
|
+
} else {
|
|
50
|
+
this.search()
|
|
16
51
|
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
keydown(event) {
|
|
55
|
+
switch (event.key) {
|
|
56
|
+
case "ArrowDown":
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
this.isOpen ? this.moveActive(1) : this.search()
|
|
59
|
+
break
|
|
60
|
+
case "ArrowUp":
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
this.moveActive(-1)
|
|
63
|
+
break
|
|
64
|
+
case "Enter":
|
|
65
|
+
if (this.isOpen && this.results[this.activeIndex]) {
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
this.choose(this.results[this.activeIndex])
|
|
68
|
+
}
|
|
69
|
+
break
|
|
70
|
+
case "Escape":
|
|
71
|
+
if (this.isOpen) {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
this.close()
|
|
74
|
+
}
|
|
75
|
+
break
|
|
76
|
+
case "Backspace":
|
|
77
|
+
if (this.multiple && this.input.value === "") {
|
|
78
|
+
this.remove(this.selectedOptions.at(-1)?.value)
|
|
79
|
+
}
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
blur() {
|
|
85
|
+
// Clearing the text of a single select clears its value
|
|
86
|
+
if (!this.multiple && this.input.value === "" && this.element.value !== "") {
|
|
87
|
+
this.choose({ id: "", name: "" })
|
|
88
|
+
}
|
|
89
|
+
this.close()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Searching
|
|
93
|
+
|
|
94
|
+
async search() {
|
|
95
|
+
const query = this.query
|
|
17
96
|
|
|
18
97
|
if (this.hasUrlValue) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
98
|
+
this.abortController?.abort()
|
|
99
|
+
this.abortController = new AbortController()
|
|
100
|
+
|
|
101
|
+
const url = new URL(this.urlValue, window.location.origin)
|
|
102
|
+
if (query) url.searchParams.set("q", query)
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(url, { headers: { Accept: "application/json" }, signal: this.abortController.signal })
|
|
106
|
+
this.open(await response.json())
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error.name !== "AbortError") this.open([])
|
|
28
109
|
}
|
|
110
|
+
} else {
|
|
111
|
+
this.open(this.localResults(query))
|
|
29
112
|
}
|
|
113
|
+
}
|
|
30
114
|
|
|
31
|
-
|
|
115
|
+
localResults(query) {
|
|
116
|
+
return Array.from(this.element.options)
|
|
117
|
+
.filter(option => option.value !== "")
|
|
118
|
+
.map(option => ({ id: option.value, name: option.text }))
|
|
119
|
+
.filter(result => result.name.toLowerCase().includes(query.toLowerCase()))
|
|
32
120
|
}
|
|
33
121
|
|
|
34
|
-
|
|
35
|
-
|
|
122
|
+
// Choosing
|
|
123
|
+
|
|
124
|
+
choose({ id, name }) {
|
|
125
|
+
const value = String(id)
|
|
126
|
+
let option = Array.from(this.element.options).find(option => option.value === value)
|
|
127
|
+
if (!option) {
|
|
128
|
+
option = new Option(name, value)
|
|
129
|
+
this.element.add(option)
|
|
130
|
+
}
|
|
131
|
+
option.selected = true
|
|
132
|
+
this.changed()
|
|
133
|
+
|
|
134
|
+
if (this.multiple) this.input.value = ""
|
|
135
|
+
this.close()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
remove(value) {
|
|
139
|
+
const option = this.selectedOptions.find(option => option.value === value)
|
|
140
|
+
if (option) {
|
|
141
|
+
option.selected = false
|
|
142
|
+
this.changed()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
changed() {
|
|
147
|
+
this.element.dispatchEvent(new Event("change", { bubbles: true }))
|
|
148
|
+
this.render()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Rendering
|
|
152
|
+
|
|
153
|
+
build() {
|
|
154
|
+
this.wrapper = document.createElement("div")
|
|
155
|
+
this.wrapper.className = "combobox"
|
|
156
|
+
|
|
157
|
+
this.control = document.createElement("div")
|
|
158
|
+
this.control.className = "combobox-control"
|
|
159
|
+
this.control.addEventListener("click", () => this.input.focus())
|
|
160
|
+
|
|
161
|
+
this.input = document.createElement("input")
|
|
162
|
+
this.input.type = "text"
|
|
163
|
+
this.input.id = `${this.element.id || `select_${++count}`}_combobox`
|
|
164
|
+
this.input.className = "combobox-input"
|
|
165
|
+
this.input.autocomplete = "off"
|
|
166
|
+
this.input.setAttribute("role", "combobox")
|
|
167
|
+
this.input.setAttribute("aria-autocomplete", "list")
|
|
168
|
+
this.input.setAttribute("aria-expanded", "false")
|
|
169
|
+
this.input.addEventListener("focus", () => this.focus())
|
|
170
|
+
this.input.addEventListener("input", () => this.typed())
|
|
171
|
+
this.input.addEventListener("keydown", event => this.keydown(event))
|
|
172
|
+
this.input.addEventListener("blur", () => this.blur())
|
|
173
|
+
|
|
174
|
+
this.listbox = document.createElement("ul")
|
|
175
|
+
this.listbox.id = `${this.input.id}_listbox`
|
|
176
|
+
this.listbox.className = "combobox-listbox"
|
|
177
|
+
this.listbox.hidden = true
|
|
178
|
+
this.listbox.setAttribute("role", "listbox")
|
|
179
|
+
// Keep focus in the input so choosing an option doesn't blur it first
|
|
180
|
+
this.listbox.addEventListener("mousedown", event => event.preventDefault())
|
|
181
|
+
this.input.setAttribute("aria-controls", this.listbox.id)
|
|
182
|
+
|
|
183
|
+
this.control.append(this.input)
|
|
184
|
+
this.wrapper.append(this.control, this.listbox)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
render() {
|
|
188
|
+
if (this.multiple) {
|
|
189
|
+
this.control.querySelectorAll(".combobox-chip").forEach(chip => chip.remove())
|
|
190
|
+
this.selectedOptions.forEach(option => this.input.before(this.chip(option)))
|
|
191
|
+
} else {
|
|
192
|
+
const option = this.selectedOptions[0]
|
|
193
|
+
this.input.value = option && option.value !== "" ? option.text : ""
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
chip(option) {
|
|
198
|
+
const chip = document.createElement("span")
|
|
199
|
+
chip.className = "combobox-chip"
|
|
200
|
+
chip.textContent = option.text
|
|
201
|
+
|
|
202
|
+
const button = document.createElement("button")
|
|
203
|
+
button.type = "button"
|
|
204
|
+
button.textContent = "×"
|
|
205
|
+
button.setAttribute("aria-label", `Remove ${option.text}`)
|
|
206
|
+
button.addEventListener("click", event => {
|
|
207
|
+
event.stopPropagation()
|
|
208
|
+
this.remove(option.value)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
chip.append(button)
|
|
212
|
+
return chip
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
open(results) {
|
|
216
|
+
const selected = this.selectedOptions.map(option => option.value)
|
|
217
|
+
this.results = this.multiple ? results.filter(result => !selected.includes(String(result.id))) : results
|
|
218
|
+
this.activeIndex = this.results.length ? 0 : -1
|
|
219
|
+
|
|
220
|
+
this.listbox.replaceChildren(...this.results.map((result, index) => {
|
|
221
|
+
const item = document.createElement("li")
|
|
222
|
+
item.id = `${this.listbox.id}_${index}`
|
|
223
|
+
item.textContent = result.name
|
|
224
|
+
item.setAttribute("role", "option")
|
|
225
|
+
item.addEventListener("click", () => this.choose(result))
|
|
226
|
+
return item
|
|
227
|
+
}))
|
|
228
|
+
|
|
229
|
+
if (this.results.length === 0) {
|
|
230
|
+
const empty = document.createElement("li")
|
|
231
|
+
empty.className = "combobox-empty"
|
|
232
|
+
empty.textContent = "No results"
|
|
233
|
+
this.listbox.append(empty)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.listbox.hidden = false
|
|
237
|
+
this.input.setAttribute("aria-expanded", "true")
|
|
238
|
+
this.highlight()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
close() {
|
|
242
|
+
clearTimeout(this.timeout)
|
|
243
|
+
this.abortController?.abort()
|
|
244
|
+
this.listbox.hidden = true
|
|
245
|
+
this.input.setAttribute("aria-expanded", "false")
|
|
246
|
+
this.input.removeAttribute("aria-activedescendant")
|
|
247
|
+
this.render()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
moveActive(step) {
|
|
251
|
+
if (this.results.length === 0) return
|
|
252
|
+
this.activeIndex = (this.activeIndex + step + this.results.length) % this.results.length
|
|
253
|
+
this.highlight()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
highlight() {
|
|
257
|
+
this.listbox.querySelectorAll("[role=option]").forEach((item, index) => {
|
|
258
|
+
const active = index === this.activeIndex
|
|
259
|
+
item.setAttribute("aria-selected", active)
|
|
260
|
+
if (active) {
|
|
261
|
+
this.input.setAttribute("aria-activedescendant", item.id)
|
|
262
|
+
item.scrollIntoView({ block: "nearest" })
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Helpers
|
|
268
|
+
|
|
269
|
+
get query() {
|
|
270
|
+
return this.multiple || this.input.value !== this.selectedOptions[0]?.text ? this.input.value.trim() : ""
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get isOpen() {
|
|
274
|
+
return !this.listbox.hidden
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get selectedOptions() {
|
|
278
|
+
return Array.from(this.element.selectedOptions).filter(option => option.value !== "")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
labelFor(id) {
|
|
282
|
+
return id ? document.querySelector(`label[for="${CSS.escape(id)}"]`) : null
|
|
36
283
|
}
|
|
37
284
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<% if filters.any? %>
|
|
2
|
+
<div class="active-filters">
|
|
3
|
+
<% filters.each do |filter| %>
|
|
4
|
+
<span class="filter-chip">
|
|
5
|
+
<span>
|
|
6
|
+
<strong><%= filter.field.label %></strong>
|
|
7
|
+
<%= t("madmin.filters.operators.#{filter.type}.#{filter.operator}") %>
|
|
8
|
+
<%= tag.strong filter_value_label(filter) if filter.value? %>
|
|
9
|
+
</span>
|
|
10
|
+
<%= link_to "×", index_path_with(filters: filters.excluding(filter).map(&:to_h).presence), aria: {label: t("madmin.filters.remove")} %>
|
|
11
|
+
</span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= link_to t("madmin.filters.clear_all"), index_path_with(filters: nil) %>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<div class="filter-row" data-filter-row>
|
|
2
|
+
<%= select_tag "filters[][column]", options_for_select(filter_column_options(resource), filter.column),
|
|
3
|
+
id: nil, aria: {label: t("madmin.filters.column")}, data: {action: "filters#changeColumn"} %>
|
|
4
|
+
|
|
5
|
+
<%= select_tag "filters[][operator]", options_for_select(filter_operator_options(filter.type), filter.operator),
|
|
6
|
+
id: nil, aria: {label: t("madmin.filters.operator")}, data: {action: "filters#changeOperator"} %>
|
|
7
|
+
|
|
8
|
+
<%= tag.input name: "filters[][value]", value: filter.value, id: nil,
|
|
9
|
+
type: filter_input_type(filter.type), step: ("any" if filter.type == :number),
|
|
10
|
+
hidden: !filter.value?, aria: {label: t("madmin.filters.value")} %>
|
|
11
|
+
|
|
12
|
+
<button type="button" class="filter-remove" aria-label="<%= t("madmin.filters.remove") %>" data-action="filters#remove">×</button>
|
|
13
|
+
</div>
|