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
@@ -0,0 +1,16 @@
1
+ module Madmin
2
+ module Fields
3
+ # PostgreSQL array columns, shown and edited as comma separated values
4
+ class Array < Field
5
+ def self.filter_type = :array
6
+
7
+ def text(record)
8
+ value(record)&.join(", ")
9
+ end
10
+
11
+ def cast(value)
12
+ value.to_s.split(",").map(&:strip).compact_blank
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,13 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Attachment < Field
4
+ # Path for removing the attachment, or nil when the attachment resource
5
+ # or its destroy route has been removed from the app
6
+ def remove_path(attachment)
7
+ resource = Madmin.resource_for(attachment)
8
+ resource.show_path(attachment) if resource.route?(:destroy)
9
+ rescue MissingResource
10
+ end
4
11
  end
5
12
  end
6
13
  end
@@ -3,7 +3,7 @@ module Madmin
3
3
  class BelongsTo < Field
4
4
  def options_for_select(record)
5
5
  current_value = record.send(attribute_name)
6
- records = [current_value].compact + associated_resource.model.excluding(current_value).limit(25)
6
+ records = [current_value].compact + model.reflect_on_association(attribute_name).klass.excluding(current_value).limit(25)
7
7
  records.map { [Madmin.resource_for(_1).display_name(_1), _1.id] }
8
8
  end
9
9
 
@@ -13,6 +13,7 @@ module Madmin
13
13
 
14
14
  def index_path(format: :json)
15
15
  associated_resource&.index_path(format: format)
16
+ rescue MissingRoute
16
17
  end
17
18
 
18
19
  def associated_resource
@@ -1,6 +1,7 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Boolean < Field
4
+ def self.filter_type = :boolean
4
5
  end
5
6
  end
6
7
  end
@@ -1,12 +1,19 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Currency < Field
4
+ def self.filter_type = :number
5
+
4
6
  def value(record)
5
7
  value = record.public_send(attribute_name)
6
8
  value /= 100.0 if value && options.minor_units
7
9
  value
8
10
  end
9
11
 
12
+ # Filter values are in major units, which minor_units columns don't store
13
+ def filter_type
14
+ super unless options.minor_units
15
+ end
16
+
10
17
  def searchable?
11
18
  options.fetch(:searchable, model.column_names.include?(attribute_name.to_s))
12
19
  end
@@ -1,6 +1,7 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Date < Field
4
+ def self.filter_type = :date
4
5
  end
5
6
  end
6
7
  end
@@ -1,6 +1,7 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class DateTime < Field
4
+ def self.filter_type = :datetime
4
5
  end
5
6
  end
6
7
  end
@@ -1,6 +1,7 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Decimal < Field
4
+ def self.filter_type = :number
4
5
  end
5
6
  end
6
7
  end
@@ -1,6 +1,7 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Float < Field
4
+ def self.filter_type = :number
4
5
  end
5
6
  end
6
7
  end
@@ -1,6 +1,8 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class HasMany < Field
4
+ include Madmin::Pagination
5
+
4
6
  def options_for_select(record)
5
7
  if (records = record.send(attribute_name))
6
8
  return [] unless records.first
@@ -17,6 +19,7 @@ module Madmin
17
19
 
18
20
  def index_path(format: :json)
19
21
  associated_resource&.index_path(format: format)
22
+ rescue MissingRoute
20
23
  end
21
24
 
22
25
  def associated_resource
@@ -33,29 +36,9 @@ module Madmin
33
36
  true
34
37
  end
35
38
 
36
- if Gem::Version.new(Pagy::VERSION) >= Gem::Version.new("43.0.0.rc")
37
- include Pagy::Method
38
-
39
- def paginated_value(record, params)
40
- page_key = "#{attribute_name}_page"
41
- request = {
42
- params: {
43
- "#{attribute_name}_page" => [params[page_key].to_i, 1].max
44
- }
45
- }
46
- pagy value(record), page_key: page_key, request: request
47
- rescue Pagy::OptionError
48
- end
49
- else
50
- include Pagy::Backend
51
-
52
- def paginated_value(record, params)
53
- page_key = "#{attribute_name}_page"
54
- page = [params[page_key].to_i, 1].max
55
- pagy value(record), page: page, page_param: page_key
56
- rescue Pagy::OverflowError, Pagy::VariableError
57
- pagy value, page: 1, page_param: page_key
58
- end
39
+ def paginated_value(record, params)
40
+ page_key = "#{attribute_name}_page"
41
+ paginate value(record), page: params[page_key], param: page_key
59
42
  end
60
43
  end
61
44
  end
@@ -1,6 +1,7 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Integer < Field
4
+ def self.filter_type = :number
4
5
  end
5
6
  end
6
7
  end
@@ -1,6 +1,27 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Json < Field
4
+ # Text that isn't JSON is shown as it is, so it can be corrected in the form
5
+ def json(record)
6
+ value = value(record)
7
+
8
+ if value.nil? || value.is_a?(::String)
9
+ value
10
+ else
11
+ JSON.pretty_generate(value)
12
+ end
13
+ end
14
+
15
+ def cast(value)
16
+ JSON.parse(value) if value.present?
17
+ rescue JSON::ParserError
18
+ value
19
+ end
20
+
21
+ # Still being text after the cast means it could not be parsed
22
+ def accepts?(value)
23
+ !value.is_a?(::String)
24
+ end
4
25
  end
5
26
  end
6
27
  end
@@ -7,7 +7,7 @@ module Madmin
7
7
  end
8
8
 
9
9
  def resource
10
- "#{to_model.name}Resource".constantize
10
+ Madmin.resource_by_name(to_model)
11
11
  end
12
12
 
13
13
  def to_param
@@ -23,7 +23,7 @@ module Madmin
23
23
  end
24
24
 
25
25
  def to_model
26
- attribute_name.to_s.singularize.classify.constantize
26
+ model.reflect_on_association(attribute_name).klass
27
27
  end
28
28
 
29
29
  def paginateable?
@@ -0,0 +1,48 @@
1
+ module Madmin
2
+ module Fields
3
+ class NestedHasOne < HasOne
4
+ DEFAULT_ATTRIBUTES = %w[_destroy id].freeze
5
+
6
+ def nested_attributes
7
+ resource.attributes.except(*skipped_fields)
8
+ end
9
+
10
+ def resource
11
+ Madmin.resource_by_name(to_model)
12
+ end
13
+
14
+ def to_param
15
+ {"#{attribute_name}_attributes": permitted_fields}
16
+ end
17
+
18
+ # Index and show link to the record like a has_one, and the form shares
19
+ # its fields with nested_has_many
20
+ def to_partial_path(name)
21
+ case name.to_s
22
+ when "index", "show"
23
+ "/madmin/fields/has_one/#{name}"
24
+ when "form"
25
+ "/madmin/fields/nested_has_one/form"
26
+ when "fields"
27
+ "/madmin/fields/nested_has_many/fields"
28
+ else
29
+ raise ArgumentError, "`partial` must be 'index', 'show', 'form' or 'fields'"
30
+ end
31
+ end
32
+
33
+ def to_model
34
+ model.reflect_on_association(attribute_name).klass
35
+ end
36
+
37
+ private
38
+
39
+ def permitted_fields
40
+ (resource.permitted_params - skipped_fields + DEFAULT_ATTRIBUTES).uniq
41
+ end
42
+
43
+ def skipped_fields
44
+ options[:skip] || []
45
+ end
46
+ end
47
+ end
48
+ end
@@ -9,10 +9,15 @@ module Madmin
9
9
  end
10
10
  end
11
11
 
12
+ # `type` is no longer used, but customized form partials may still submit it
12
13
  def to_param
13
14
  {attribute_name => %i[type value]}
14
15
  end
15
16
 
17
+ def cast(value)
18
+ GlobalID::Locator.locate(value[:value])
19
+ end
20
+
16
21
  def associated_resource_for(object)
17
22
  Madmin.resource_for(object)
18
23
  rescue MissingResource
@@ -1,6 +1,8 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class String < Field
4
+ def self.filter_type = :string
5
+
4
6
  def searchable?
5
7
  options.fetch(:searchable, resource.model_column_names.include?(attribute_name.to_s))
6
8
  end
@@ -1,6 +1,8 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Text < Field
4
+ def self.filter_type = :string
5
+
4
6
  def searchable?
5
7
  options.fetch(:searchable, resource.model_column_names.include?(attribute_name.to_s))
6
8
  end
@@ -0,0 +1,96 @@
1
+ module Madmin
2
+ # One condition from the index filters, like `created_at gte 2026-08-15T09:00`
3
+ class Filter
4
+ OPERATORS = {
5
+ string: %w[contains eq starts_with blank present],
6
+ number: %w[eq gt gte lt lte blank],
7
+ date: %w[eq gte lte blank],
8
+ datetime: %w[gte lte blank],
9
+ boolean: %w[true false],
10
+ array: %w[includes]
11
+ }.freeze
12
+
13
+ WITHOUT_VALUE = %w[blank present true false].freeze
14
+
15
+ attr_reader :field, :operator, :value
16
+
17
+ # Skips conditions for unknown columns or operators, and values that don't
18
+ # cast to the column's type
19
+ def self.from_params(resource, params)
20
+ params.map { |param| new(resource.get_attribute(param[:column].to_s.to_sym)&.field, param[:operator].to_s, param[:value].to_s) }.select(&:valid?)
21
+ end
22
+
23
+ # A new row in the filters form, for the first filterable column
24
+ def self.blank(resource)
25
+ field = resource.filterable_attributes.first.field
26
+ new(field, OPERATORS.fetch(field.filter_type).first, "")
27
+ end
28
+
29
+ def initialize(field, operator, value)
30
+ @field = field
31
+ @operator = operator
32
+ @value = value
33
+ end
34
+
35
+ def column
36
+ field.attribute_name.to_s
37
+ end
38
+
39
+ def type
40
+ field.filter_type
41
+ end
42
+
43
+ def value?
44
+ WITHOUT_VALUE.exclude?(operator)
45
+ end
46
+
47
+ def valid?
48
+ field&.filter_type.present? && OPERATORS[type].include?(operator) && (typed_value.present? || !value?)
49
+ end
50
+
51
+ def apply(scope)
52
+ attribute = scope.arel_table[column]
53
+
54
+ case operator
55
+ when "contains" then scope.where(attribute.matches("%#{escaped_value}%"))
56
+ when "starts_with" then scope.where(attribute.matches("#{escaped_value}%"))
57
+ when "eq" then scope.where(column => typed_value)
58
+ when "gt" then scope.where(attribute.gt(typed_value))
59
+ when "gte" then scope.where(attribute.gteq(typed_value))
60
+ when "lt" then scope.where(attribute.lt(typed_value))
61
+ when "lte" then scope.where(attribute.lteq(typed_value))
62
+ when "blank" then scope.where(column => blank_values)
63
+ when "present" then scope.where.not(column => blank_values)
64
+ when "true" then scope.where(column => true)
65
+ when "false" then scope.where(column => false)
66
+ when "includes" then scope.where(Arel::Nodes.build_quoted(typed_value).eq(Arel::Nodes::NamedFunction.new("ANY", [attribute])))
67
+ end
68
+ end
69
+
70
+ # The params for this filter, used to keep it in links
71
+ def to_h
72
+ {column: column, operator: operator, value: value}
73
+ end
74
+
75
+ # The value in the column's type, or its element type for arrays. Datetimes are parsed in Time.zone, like form input
76
+ def typed_value
77
+ @typed_value ||= value.presence && value_type.cast(value)
78
+ end
79
+
80
+ private
81
+
82
+ # Arrays filter by their elements
83
+ def value_type
84
+ attribute_type = field.model.type_for_attribute(column)
85
+ (type == :array) ? attribute_type.subtype : attribute_type
86
+ end
87
+
88
+ def escaped_value
89
+ field.model.sanitize_sql_like(value)
90
+ end
91
+
92
+ def blank_values
93
+ (type == :string) ? [nil, ""] : nil
94
+ end
95
+ end
96
+ end
data/lib/madmin/menu.rb CHANGED
@@ -36,8 +36,9 @@ module Madmin
36
36
  @children[parent] ||= Item.new(label: parent)
37
37
  @children[parent].add options
38
38
  else
39
+ key = options.delete(:key)
39
40
  item = Item.new(**options)
40
- @children[item.label] = item
41
+ @children[key || item.label] = item
41
42
  end
42
43
  end
43
44
 
@@ -0,0 +1,61 @@
1
+ module Madmin
2
+ # Holds the numbers for a single page of results. Knows nothing about the database.
3
+ #
4
+ # Non-ActiveRecord adapters overriding `paginate_collection` can return one of
5
+ # these (or any object responding to the same methods) alongside their records.
6
+ class Page
7
+ SERIES_SLOTS = 7
8
+
9
+ attr_reader :count, :page, :per_page, :param
10
+
11
+ def initialize(count:, page: 1, per_page: Madmin.per_page, param: :page)
12
+ @count = count.to_i
13
+ @per_page = [per_page.to_i, 1].max
14
+ @page = [page.to_i, 1].max
15
+ @param = param.to_s
16
+ end
17
+
18
+ def last
19
+ [(count.to_f / per_page).ceil, 1].max
20
+ end
21
+
22
+ # Pages past the end are served empty rather than raising or clamping
23
+ def overflow?
24
+ page > last
25
+ end
26
+
27
+ def offset
28
+ (page - 1) * per_page
29
+ end
30
+
31
+ def from
32
+ (count.zero? || overflow?) ? 0 : offset + 1
33
+ end
34
+
35
+ def to
36
+ overflow? ? 0 : [offset + per_page, count].min
37
+ end
38
+
39
+ def prev
40
+ [page - 1, last].min if page > 1
41
+ end
42
+
43
+ def next
44
+ page + 1 if page < last
45
+ end
46
+
47
+ # Page numbers to link to, with :gap where pages are skipped
48
+ # [1, :gap, 9, 10, 11, :gap, 20]
49
+ def series(slots: SERIES_SLOTS)
50
+ return (1..last).to_a if last <= slots
51
+
52
+ start = (page - (slots - 1) / 2).clamp(1, last - slots + 1)
53
+ pages = (start...(start + slots)).to_a
54
+ pages[0] = 1
55
+ pages[1] = :gap unless pages[1] == 2
56
+ pages[-1] = last
57
+ pages[-2] = :gap unless pages[-2] == last - 1
58
+ pages
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,50 @@
1
+ module Madmin
2
+ # Paginates ActiveRecord relations using only the relation API (count, order,
3
+ # offset, limit) so the SQL is generated by whichever database adapter is in use.
4
+ module Pagination
5
+ private
6
+
7
+ # Returns [Madmin::Page, records]
8
+ def paginate(collection, page: 1, per_page: Madmin.per_page, param: :page)
9
+ page = Madmin::Page.new(count: count_for(collection), page: page, per_page: per_page, param: param)
10
+ [page, with_tiebreaker(collection).offset(page.offset).limit(page.per_page)]
11
+ end
12
+
13
+ def count_for(collection)
14
+ # count(:all) keeps custom selects valid, and ordering is irrelevant to a count
15
+ count = collection.unscope(:order).count(:all)
16
+
17
+ # Grouped relations return a Hash of counts per group
18
+ count.is_a?(Hash) ? count.size : count
19
+ end
20
+
21
+ # Appends the primary key to the ordering so rows with equal sort values keep
22
+ # a stable position across pages. It follows the direction of the existing
23
+ # ordering so indexes on the sort column can still be used.
24
+ def with_tiebreaker(collection)
25
+ return collection if collection.group_values.any? || collection.distinct_value
26
+
27
+ primary_keys = Array(collection.klass.primary_key).map(&:to_s)
28
+ return collection if primary_keys.empty?
29
+
30
+ orders = collection.order_values
31
+ return collection if orders.any? { |order| orders_by?(order, primary_keys) }
32
+
33
+ direction = descending?(orders.last) ? :desc : :asc
34
+ table = collection.klass.arel_table
35
+ collection.order(primary_keys.map { |key| table[key].public_send(direction) })
36
+ end
37
+
38
+ def orders_by?(order, column_names)
39
+ order.is_a?(Arel::Nodes::Ordering) && order.expr.respond_to?(:name) && column_names.include?(order.expr.name.to_s)
40
+ end
41
+
42
+ def descending?(order)
43
+ case order
44
+ when Arel::Nodes::Descending then true
45
+ when String then order.match?(/\bdesc\s*\z/i)
46
+ else false
47
+ end
48
+ end
49
+ end
50
+ end
@@ -37,6 +37,13 @@ module Madmin
37
37
  scopes << name
38
38
  end
39
39
 
40
+ # Label for a scope button on the index. Looks up
41
+ # madmin.scopes.<param_key>.<name>, then madmin.scopes.<name> in I18n,
42
+ # falling back to the humanized scope name. Override for custom labels.
43
+ def scope_label(name)
44
+ I18n.t("madmin.scopes.#{param_key}.#{name}", default: [:"madmin.scopes.#{name}", name.to_s.humanize])
45
+ end
46
+
40
47
  def get_attribute(name)
41
48
  attributes[name]
42
49
  end
@@ -76,8 +83,21 @@ module Madmin
76
83
 
77
84
  # Returns singular name
78
85
  # For example: "Forum::Post" -> "Forum / Post"
86
+ #
87
+ # Uses the model's translation (`activerecord.models.forum/post`) when there is one
79
88
  def friendly_name
80
- model_name.split("::").map { |part| part.underscore.humanize }.join(" / ").titlecase
89
+ translated_name || model_name.split("::").map { |part| part.underscore.humanize }.join(" / ").titlecase
90
+ end
91
+
92
+ # Returns plural name
93
+ # For example: "Forum::Post" -> "Forum / Posts"
94
+ def friendly_plural_name
95
+ singular = translated_name
96
+ return friendly_name.pluralize unless singular
97
+
98
+ # A translation without `one` / `other` forms is the same for every count
99
+ return translated_name(count: 2) if plural_translation? || !locale_inflections?
100
+ singular.pluralize(I18n.locale)
81
101
  end
82
102
 
83
103
  # Support for isolated namespaces
@@ -91,19 +111,28 @@ module Madmin
91
111
  end
92
112
 
93
113
  def index_path(options = {})
94
- url_helpers.polymorphic_path([:madmin, route_namespace, model], options)
114
+ route_path([:madmin, route_namespace, model], options)
95
115
  end
96
116
 
97
117
  def new_path
98
- url_helpers.polymorphic_path([:madmin, route_namespace, model], action: :new)
118
+ route_path([:madmin, route_namespace, model], action: :new)
99
119
  end
100
120
 
101
121
  def show_path(record)
102
- url_helpers.polymorphic_path([:madmin, route_namespace, becomes(record)])
122
+ route_path([:madmin, route_namespace, becomes(record)])
103
123
  end
104
124
 
105
125
  def edit_path(record)
106
- url_helpers.polymorphic_path([:madmin, route_namespace, becomes(record)], action: :edit)
126
+ route_path([:madmin, route_namespace, becomes(record)], action: :edit)
127
+ end
128
+
129
+ # Whether a route is drawn for the given action on this resource's controller
130
+ # For example: PostResource.route?(:destroy)
131
+ def route?(action)
132
+ controller = "madmin/#{model.model_name.collection}"
133
+ Rails.application.routes.routes.any? do |route|
134
+ route.defaults[:controller] == controller && route.defaults[:action] == action.to_s
135
+ end
107
136
  end
108
137
 
109
138
  def becomes(record)
@@ -142,6 +171,10 @@ module Madmin
142
171
  attributes.values.select { |a| a.field.searchable? }
143
172
  end
144
173
 
174
+ def filterable_attributes
175
+ attributes.values.select { |a| a.field&.filter_type }
176
+ end
177
+
145
178
  def member_action(collection: false, &block)
146
179
  member_actions << MemberAction.new(collection: collection, &block)
147
180
  end
@@ -157,6 +190,7 @@ module Madmin
157
190
 
158
191
  def field_for_type(type)
159
192
  {
193
+ array: Fields::Array,
160
194
  binary: Fields::String,
161
195
  blob: Fields::Text,
162
196
  boolean: Fields::Boolean,
@@ -216,7 +250,8 @@ module Madmin
216
250
  has_many: Fields::HasMany,
217
251
  has_one: Fields::HasOne,
218
252
  rich_text: Fields::RichText,
219
- nested_has_many: Fields::NestedHasMany
253
+ nested_has_many: Fields::NestedHasMany,
254
+ nested_has_one: Fields::NestedHasOne
220
255
  }[type]
221
256
  end
222
257
 
@@ -227,6 +262,8 @@ module Madmin
227
262
  column_type = model.attribute_types[name_string]
228
263
  if column_type.is_a? ::ActiveRecord::Enum::EnumType
229
264
  :enum
265
+ elsif model.columns_hash[name_string].try(:array?)
266
+ :array
230
267
  else
231
268
  column_type.type || :string
232
269
  end
@@ -265,6 +302,26 @@ module Madmin
265
302
  @url_helpers ||= Rails.application.routes.url_helpers
266
303
  end
267
304
 
305
+ # Raises MissingRoute with instructions instead of a NoMethodError for
306
+ # the undefined route helper when the resource's routes aren't drawn
307
+ def route_path(components, options = {})
308
+ url_helpers.polymorphic_path(components, options)
309
+ rescue NoMethodError => error
310
+ raise unless error.name.to_s.end_with?("_path")
311
+
312
+ *namespaces, resources = model.model_name.collection.split("/")
313
+ routes = namespaces.reverse.inject("resources :#{resources}") do |body, namespace|
314
+ "namespace :#{namespace} do\n#{body.indent(2)}\nend"
315
+ end
316
+
317
+ raise MissingRoute, [
318
+ "`#{error.name}` is not defined, so #{name} has no route for this page.",
319
+ "Add the route inside `namespace :madmin` in your routes:",
320
+ routes.indent(4),
321
+ "To hide the resource from the menu instead, add `menu false` to #{name}."
322
+ ].join("\n\n")
323
+ end
324
+
268
325
  def model_store_accessors
269
326
  return [] unless model.respond_to?(:stored_attributes)
270
327
 
@@ -279,7 +336,28 @@ module Madmin
279
336
  def menu_options
280
337
  return false if @menu_options == false
281
338
  @menu_options ||= {}
282
- @menu_options.with_defaults(label: friendly_name.pluralize, url: index_path)
339
+ # The default label follows the locale, so the menu tracks the item by resource instead
340
+ @menu_options.with_defaults(key: @menu_options[:label] || name, label: friendly_plural_name, url: index_path)
341
+ end
342
+
343
+ private
344
+
345
+ def translated_name(count: 1)
346
+ model.model_name.human(count: count, default: "").presence
347
+ end
348
+
349
+ # Whether the model's translation spells out its own `one` / `other` forms
350
+ def plural_translation?
351
+ model.lookup_ancestors.each do |klass|
352
+ translation = I18n.t(klass.model_name.i18n_key, scope: [klass.i18n_scope, :models], default: "")
353
+ return translation.is_a?(Hash) if translation.present?
354
+ end
355
+ false
356
+ end
357
+
358
+ # Locales without inflection rules of their own (most non-English ones) are left unpluralized
359
+ def locale_inflections?
360
+ ActiveSupport::Inflector::Inflections.instance(I18n.locale).plurals.any?
283
361
  end
284
362
  end
285
363