madmin 2.6.0 → 3.1.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -4
  3. data/app/assets/stylesheets/madmin/actiontext.css +31 -0
  4. data/app/assets/stylesheets/madmin/base.css +22 -11
  5. data/app/assets/stylesheets/madmin/buttons.css +9 -9
  6. data/app/assets/stylesheets/madmin/forms.css +263 -7
  7. data/app/assets/stylesheets/madmin/pagination.css +10 -23
  8. data/app/assets/stylesheets/madmin/reset.css +1 -1
  9. data/app/assets/stylesheets/madmin/sidebar.css +6 -6
  10. data/app/assets/stylesheets/madmin/tables.css +1 -1
  11. data/app/controllers/madmin/base_controller.rb +1 -5
  12. data/app/controllers/madmin/resource_controller.rb +38 -17
  13. data/app/helpers/madmin/application_helper.rb +15 -2
  14. data/app/helpers/madmin/filters_helper.rb +26 -0
  15. data/app/helpers/madmin/sort_helper.rb +1 -1
  16. data/app/javascript/madmin/application.js +4 -0
  17. data/app/javascript/madmin/controllers/application.js +0 -3
  18. data/app/javascript/madmin/controllers/filters_controller.js +35 -0
  19. data/app/javascript/madmin/controllers/nested_form_controller.js +5 -0
  20. data/app/javascript/madmin/controllers/select_controller.js +269 -22
  21. data/app/views/layouts/madmin/application.html.erb +1 -0
  22. data/app/views/madmin/application/_active_filters.html.erb +15 -0
  23. data/app/views/madmin/application/_filter.html.erb +13 -0
  24. data/app/views/madmin/application/_filters.html.erb +32 -0
  25. data/app/views/madmin/application/_javascript.html.erb +0 -2
  26. data/app/views/madmin/application/_pagination.html.erb +32 -0
  27. data/app/views/madmin/application/edit.html.erb +1 -1
  28. data/app/views/madmin/application/index.html.erb +13 -12
  29. data/app/views/madmin/application/new.html.erb +1 -1
  30. data/app/views/madmin/application/show.html.erb +1 -1
  31. data/app/views/madmin/fields/array/_form.html.erb +1 -0
  32. data/app/views/madmin/fields/array/_index.html.erb +1 -0
  33. data/app/views/madmin/fields/array/_show.html.erb +1 -0
  34. data/app/views/madmin/fields/attachment/_form.html.erb +1 -5
  35. data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
  36. data/app/views/madmin/fields/attachments/_form.html.erb +1 -1
  37. data/app/views/madmin/fields/attachments/_index.html.erb +1 -1
  38. data/app/views/madmin/fields/belongs_to/_form.html.erb +1 -1
  39. data/app/views/madmin/fields/belongs_to/_index.html.erb +5 -2
  40. data/app/views/madmin/fields/belongs_to/_show.html.erb +1 -1
  41. data/app/views/madmin/fields/enum/_form.html.erb +1 -1
  42. data/app/views/madmin/fields/file/_form.html.erb +1 -1
  43. data/app/views/madmin/fields/has_many/_form.html.erb +1 -1
  44. data/app/views/madmin/fields/has_many/_index.html.erb +1 -1
  45. data/app/views/madmin/fields/has_many/_show.html.erb +3 -11
  46. data/app/views/madmin/fields/has_one/_index.html.erb +5 -1
  47. data/app/views/madmin/fields/has_one/_show.html.erb +1 -1
  48. data/app/views/madmin/fields/json/_form.html.erb +1 -1
  49. data/app/views/madmin/fields/json/_show.html.erb +1 -1
  50. data/app/views/madmin/fields/nested_has_many/_index.html.erb +1 -1
  51. data/app/views/madmin/fields/nested_has_many/_show.html.erb +3 -11
  52. data/app/views/madmin/fields/nested_has_one/_form.html.erb +18 -0
  53. data/app/views/madmin/fields/polymorphic/_form.html.erb +1 -2
  54. data/app/views/madmin/fields/polymorphic/_index.html.erb +5 -1
  55. data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
  56. data/app/views/madmin/fields/select/_form.html.erb +1 -1
  57. data/config/importmap.rb +0 -3
  58. data/config/locales/el.yml +44 -0
  59. data/config/locales/en.yml +44 -0
  60. data/config/locales/fr.yml +44 -0
  61. data/config/locales/ko.yml +44 -0
  62. data/lib/madmin/field.rb +27 -1
  63. data/lib/madmin/fields/array.rb +16 -0
  64. data/lib/madmin/fields/attachment.rb +7 -0
  65. data/lib/madmin/fields/belongs_to.rb +2 -1
  66. data/lib/madmin/fields/boolean.rb +1 -0
  67. data/lib/madmin/fields/currency.rb +7 -0
  68. data/lib/madmin/fields/date.rb +1 -0
  69. data/lib/madmin/fields/date_time.rb +1 -0
  70. data/lib/madmin/fields/decimal.rb +1 -0
  71. data/lib/madmin/fields/float.rb +1 -0
  72. data/lib/madmin/fields/has_many.rb +6 -23
  73. data/lib/madmin/fields/integer.rb +1 -0
  74. data/lib/madmin/fields/json.rb +21 -0
  75. data/lib/madmin/fields/nested_has_many.rb +2 -2
  76. data/lib/madmin/fields/nested_has_one.rb +48 -0
  77. data/lib/madmin/fields/polymorphic.rb +5 -0
  78. data/lib/madmin/fields/string.rb +2 -0
  79. data/lib/madmin/fields/text.rb +2 -0
  80. data/lib/madmin/filter.rb +96 -0
  81. data/lib/madmin/menu.rb +2 -1
  82. data/lib/madmin/page.rb +61 -0
  83. data/lib/madmin/pagination.rb +50 -0
  84. data/lib/madmin/resource.rb +85 -7
  85. data/lib/madmin/search.rb +5 -7
  86. data/lib/madmin/version.rb +1 -1
  87. data/lib/madmin.rb +45 -2
  88. metadata +17 -16
@@ -28,11 +28,11 @@
28
28
  margin-bottom: 0.1rem;
29
29
 
30
30
  &:hover {
31
- background-color: rgb(243 244 246);
31
+ background-color: var(--hover-color);
32
32
  }
33
33
 
34
34
  &.active {
35
- background-color: rgb(243 244 246);
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: rgb(243 244 246);
93
+ background-color: var(--hover-color);
94
94
  }
95
95
 
96
96
  &.active {
97
- background-color: rgb(243 244 246);
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: rgb(75 85 99);
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(--primary-color);
122
+ color: var(--link-color);
123
123
  }
124
124
  }
125
125
  }
@@ -21,7 +21,7 @@ table {
21
21
  text-align: left;
22
22
 
23
23
  a {
24
- color: rgb(17 24 39);
24
+ color: var(--text-color);
25
25
  text-decoration: none;
26
26
  }
27
27
 
@@ -2,11 +2,7 @@ module Madmin
2
2
  class BaseController < ActionController::Base
3
3
  include ::ActiveStorage::SetCurrent if defined?(::ActiveStorage)
4
4
 
5
- if Gem::Version.new(Pagy::VERSION) >= Gem::Version.new("43.0.0.rc")
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
 
@@ -9,7 +9,7 @@ module Madmin
9
9
  before_action :set_paper_trail_whodunnit, if: -> { respond_to?(:set_paper_trail_whodunnit, true) }
10
10
 
11
11
  def index
12
- @pagy, @records = paginate_collection(scoped_resources)
12
+ @page, @records = paginate_collection(scoped_resources)
13
13
 
14
14
  respond_to do |format|
15
15
  format.html
@@ -27,8 +27,8 @@ module Madmin
27
27
  end
28
28
 
29
29
  def create
30
- @record = resource.model.new(resource_params)
31
- if @record.save
30
+ @record = resource.model.new
31
+ if save_record(resource_params)
32
32
  redirect_to resource.show_path(@record)
33
33
  else
34
34
  render :new, status: :unprocessable_entity
@@ -39,7 +39,7 @@ module Madmin
39
39
  end
40
40
 
41
41
  def update
42
- if @record.update(resource_params)
42
+ if save_record(resource_params)
43
43
  redirect_to resource.show_path(@record)
44
44
  else
45
45
  render :edit, status: :unprocessable_entity
@@ -68,15 +68,17 @@ module Madmin
68
68
 
69
69
  def scoped_resources
70
70
  resources = resource.model.send(valid_scope)
71
- resources = Madmin::Search.new(resources, resource, search_term).run
71
+ resources = Madmin::Search.new(resources, resource, search_term, filters).run
72
72
 
73
73
  return resources if sort_column.blank?
74
74
 
75
75
  resources.reorder(sort_column => sort_direction)
76
76
  end
77
77
 
78
+ # Returns [page, records]. Override for non-ActiveRecord collections and
79
+ # return an object responding to the Madmin::Page interface.
78
80
  def paginate_collection(collection)
79
- pagy(collection)
81
+ paginate(collection, page: params[:page])
80
82
  end
81
83
 
82
84
  def valid_scope
@@ -85,31 +87,50 @@ module Madmin
85
87
  end
86
88
 
87
89
  def resource_params
88
- params.require(resource.param_key)
89
- .permit(*resource.permitted_params)
90
- .transform_values { |v| change_polymorphic(v) }
90
+ cast_fields params.require(resource.param_key).permit(*resource.permitted_params)
91
91
  end
92
92
 
93
93
  def new_resource_params
94
- params.fetch(resource.param_key, {}).permit!
95
- .permit(*resource.permitted_params)
96
- .transform_values { |v| change_polymorphic(v) }
94
+ cast_fields params.fetch(resource.param_key, {}).permit!.permit(*resource.permitted_params)
97
95
  end
98
96
 
99
- def change_polymorphic(data)
100
- return data unless data.is_a?(ActionController::Parameters) && data[:type]
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
103
+ end
104
+
105
+ # Assigns the attributes and saves, unless a field doesn't accept its value
106
+ def save_record(attributes)
107
+ @record.assign_attributes(attributes)
108
+
109
+ rejected = attributes.select { |name, value| field_for(name)&.accepts?(value) == false }.keys
101
110
 
102
- if data[:type] == "polymorphic"
103
- GlobalID::Locator.locate(data[:value])
111
+ if rejected.any?
112
+ @record.validate
113
+ rejected.each { |name| @record.errors.add(name, :invalid) }
114
+ false
104
115
  else
105
- raise "Unrecognised param data: #{data.inspect}"
116
+ @record.save
106
117
  end
107
118
  end
108
119
 
120
+ def field_for(name)
121
+ resource.get_attribute(name.to_sym)&.field
122
+ end
123
+
109
124
  def search_term
110
125
  @search_term ||= params[:q].to_s.strip
111
126
  end
112
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
+
113
134
  def enforce_readonly
114
135
  redirect_to resource.index_path, alert: t("madmin.flash.readonly", name: resource.friendly_name) if resource.readonly?
115
136
  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
- resource.index_path(sort: params[:sort], direction: params[:direction])
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 resource.index_path(sort: column, direction: direction, scope: params[:scope], q: params[:q]), options do
7
+ link_to index_path_with(sort: column, direction: direction), options do
8
8
  concat title
9
9
  if matching_column
10
10
  concat " "
@@ -1,3 +1,7 @@
1
1
  import "@hotwired/turbo-rails"
2
2
  import "@rails/actiontext"
3
+ import * as ActiveStorage from "@rails/activestorage"
3
4
  import "controllers"
5
+
6
+ // Uploads files straight to storage for fields with `direct_upload: true`. Other file fields submit with the form
7
+ ActiveStorage.start()
@@ -7,6 +7,3 @@ application.debug = false
7
7
  window.Stimulus = application
8
8
 
9
9
  export { application }
10
-
11
- import { Dropdown } from "tailwindcss-stimulus-components"
12
- application.register("dropdown", Dropdown)
@@ -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
- let options = {
12
- plugins: ['remove_button'],
13
- valueField: 'id',
14
- labelField: 'name',
15
- searchField: 'name',
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
- options["load"] = (search, callback) => {
20
- let url = search ? `${this.urlValue}?q=${search}` : this.urlValue;
21
- fetch(url)
22
- .then(response => response.json())
23
- .then(json => {
24
- callback(json);
25
- }).catch(() => {
26
- callback();
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
- this.select = new TomSelect(this.element, options)
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
- disconnect() {
35
- this.select.destroy()
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
  }
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="ROBOTS" content="NOODP">
6
6
  <meta name="viewport" content="initial-scale=1">
7
+ <meta name="color-scheme" content="light dark">
7
8
  <title>
8
9
  <% if content_for? :title %>
9
10
  <%= yield(:title) %> -
@@ -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">&times;</button>
13
+ </div>