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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +121 -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 +5 -1
  11. data/app/controllers/madmin/base_controller.rb +1 -5
  12. data/app/controllers/madmin/resource_controller.rb +49 -16
  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 +2 -2
  16. data/app/javascript/madmin/controllers/filters_controller.js +35 -0
  17. data/app/javascript/madmin/controllers/nested_form_controller.js +5 -0
  18. data/app/javascript/madmin/controllers/select_controller.js +269 -22
  19. data/app/views/layouts/madmin/application.html.erb +1 -0
  20. data/app/views/madmin/application/_active_filters.html.erb +15 -0
  21. data/app/views/madmin/application/_filter.html.erb +13 -0
  22. data/app/views/madmin/application/_filters.html.erb +32 -0
  23. data/app/views/madmin/application/_javascript.html.erb +0 -2
  24. data/app/views/madmin/application/_pagination.html.erb +32 -0
  25. data/app/views/madmin/application/edit.html.erb +1 -1
  26. data/app/views/madmin/application/index.html.erb +18 -15
  27. data/app/views/madmin/application/new.html.erb +1 -1
  28. data/app/views/madmin/application/show.html.erb +5 -3
  29. data/app/views/madmin/fields/array/_form.html.erb +1 -0
  30. data/app/views/madmin/fields/array/_index.html.erb +1 -0
  31. data/app/views/madmin/fields/array/_show.html.erb +1 -0
  32. data/app/views/madmin/fields/attachment/_form.html.erb +0 -4
  33. data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
  34. data/app/views/madmin/fields/attachments/_index.html.erb +1 -1
  35. data/app/views/madmin/fields/belongs_to/_form.html.erb +1 -1
  36. data/app/views/madmin/fields/belongs_to/_index.html.erb +5 -2
  37. data/app/views/madmin/fields/belongs_to/_show.html.erb +1 -1
  38. data/app/views/madmin/fields/enum/_form.html.erb +1 -1
  39. data/app/views/madmin/fields/has_many/_form.html.erb +1 -1
  40. data/app/views/madmin/fields/has_many/_index.html.erb +1 -1
  41. data/app/views/madmin/fields/has_many/_show.html.erb +3 -11
  42. data/app/views/madmin/fields/has_one/_index.html.erb +5 -1
  43. data/app/views/madmin/fields/has_one/_show.html.erb +1 -1
  44. data/app/views/madmin/fields/json/_form.html.erb +1 -1
  45. data/app/views/madmin/fields/json/_show.html.erb +1 -1
  46. data/app/views/madmin/fields/nested_has_many/_index.html.erb +1 -1
  47. data/app/views/madmin/fields/nested_has_many/_show.html.erb +3 -11
  48. data/app/views/madmin/fields/nested_has_one/_form.html.erb +18 -0
  49. data/app/views/madmin/fields/polymorphic/_form.html.erb +1 -2
  50. data/app/views/madmin/fields/polymorphic/_index.html.erb +5 -1
  51. data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
  52. data/app/views/madmin/fields/select/_form.html.erb +1 -1
  53. data/config/importmap.rb +0 -1
  54. data/config/locales/el.yml +86 -0
  55. data/config/locales/en.yml +46 -0
  56. data/config/locales/fr.yml +46 -0
  57. data/config/locales/ko.yml +84 -0
  58. data/lib/madmin/field.rb +27 -1
  59. data/lib/madmin/fields/array.rb +16 -0
  60. data/lib/madmin/fields/attachment.rb +7 -0
  61. data/lib/madmin/fields/belongs_to.rb +2 -1
  62. data/lib/madmin/fields/boolean.rb +1 -0
  63. data/lib/madmin/fields/currency.rb +7 -0
  64. data/lib/madmin/fields/date.rb +1 -0
  65. data/lib/madmin/fields/date_time.rb +1 -0
  66. data/lib/madmin/fields/decimal.rb +1 -0
  67. data/lib/madmin/fields/float.rb +1 -0
  68. data/lib/madmin/fields/has_many.rb +6 -23
  69. data/lib/madmin/fields/integer.rb +1 -0
  70. data/lib/madmin/fields/json.rb +21 -0
  71. data/lib/madmin/fields/nested_has_many.rb +2 -2
  72. data/lib/madmin/fields/nested_has_one.rb +48 -0
  73. data/lib/madmin/fields/polymorphic.rb +5 -0
  74. data/lib/madmin/fields/string.rb +3 -1
  75. data/lib/madmin/fields/text.rb +3 -1
  76. data/lib/madmin/filter.rb +96 -0
  77. data/lib/madmin/menu.rb +2 -1
  78. data/lib/madmin/page.rb +61 -0
  79. data/lib/madmin/pagination.rb +50 -0
  80. data/lib/madmin/resource.rb +95 -7
  81. data/lib/madmin/resource_builder.rb +8 -0
  82. data/lib/madmin/search.rb +5 -7
  83. data/lib/madmin/version.rb +1 -1
  84. data/lib/madmin.rb +47 -2
  85. metadata +19 -16
@@ -0,0 +1,84 @@
1
+ ko:
2
+ madmin:
3
+ layout:
4
+ open_menu: 메뉴 열기
5
+ admin_suffix: 관리자
6
+ navigation:
7
+ dashboard: 대시보드
8
+ github: GitHub에서 Madmin 보기
9
+ dashboard:
10
+ title: Madmin 대시보드
11
+ hint_html: 대시보드를 원하는 대로 꾸미려면 <code>app/views/madmin/dashboard/show.html.erb</code> 파일을 생성하세요.
12
+ actions:
13
+ all: 전체
14
+ view: 보기
15
+ edit: 수정
16
+ delete: 삭제
17
+ new: 새로 만들기
18
+ remove: 제거
19
+ cancel: 취소
20
+ search:
21
+ placeholder: 검색
22
+ titles:
23
+ new: 새 %{name}
24
+ edit: "%{name} 수정"
25
+ form:
26
+ errors:
27
+ one: 제출한 내용에 오류가 1개 있습니다
28
+ other: 제출한 내용에 오류가 %{count}개 있습니다
29
+ confirmations:
30
+ delete: 정말 삭제하시겠습니까?
31
+ remove_with_changes: 정말 제거하시겠습니까? 다른 변경 사항도 함께 사라집니다.
32
+ nested_form:
33
+ add_new: "+ 새로 추가"
34
+ fields:
35
+ attachment:
36
+ one: "첨부파일 %{count}개"
37
+ other: "첨부파일 %{count}개"
38
+ missing_resource:
39
+ message: "%{resource}이(가) 존재하지 않습니다."
40
+ create_hint: "다음 명령어를 실행하여 생성하세요:"
41
+ pagination:
42
+ label: 페이지 탐색
43
+ previous: 이전
44
+ next: 다음
45
+ info: "%{count}개 중 %{from}-%{to} 표시"
46
+ empty: 레코드가 없습니다
47
+ filters:
48
+ button: "필터"
49
+ heading: "다음 조건의 레코드 표시"
50
+ add: "+ 필터 추가"
51
+ apply: "적용"
52
+ clear_all: "필터 지우기"
53
+ remove: "필터 제거"
54
+ column: "열"
55
+ operator: "조건"
56
+ value: "값"
57
+ operators:
58
+ string:
59
+ contains: "포함"
60
+ eq: "일치"
61
+ starts_with: "시작"
62
+ blank: "비어 있음"
63
+ present: "비어 있지 않음"
64
+ number:
65
+ eq: "="
66
+ gt: ">"
67
+ gte: "≥"
68
+ lt: "<"
69
+ lte: "≤"
70
+ blank: "비어 있음"
71
+ date:
72
+ eq: "당일"
73
+ gte: "이후(당일 포함)"
74
+ lte: "이전(당일 포함)"
75
+ blank: "비어 있음"
76
+ datetime:
77
+ gte: "이후"
78
+ lte: "이전"
79
+ blank: "비어 있음"
80
+ boolean:
81
+ "true": "참"
82
+ "false": "거짓"
83
+ array:
84
+ includes: "포함"
data/lib/madmin/field.rb CHANGED
@@ -6,6 +6,10 @@ module Madmin
6
6
  to_s.split("::").last.underscore
7
7
  end
8
8
 
9
+ # The kind of index filter a field offers (:string, :number, :date,
10
+ # :datetime or :boolean), or nil for none
11
+ def self.filter_type = nil
12
+
9
13
  def initialize(attribute_name:, model:, resource:, options:)
10
14
  @attribute_name = attribute_name.to_sym
11
15
  @model = model
@@ -29,8 +33,18 @@ module Madmin
29
33
  attribute_name
30
34
  end
31
35
 
36
+ # Converts the submitted form value into the value assigned to the record
37
+ def cast(value)
38
+ value
39
+ end
40
+
41
+ # Whether the value returned by `cast` can be saved. The form is shown again with an error when it can't.
42
+ def accepts?(value)
43
+ true
44
+ end
45
+
32
46
  def label
33
- options[:label].presence || attribute_name.to_s.titleize
47
+ options[:label].presence || model.human_attribute_name(attribute_name)
34
48
  end
35
49
 
36
50
  # Used for checking visibility of attribute on an view
@@ -62,6 +76,18 @@ module Madmin
62
76
  false
63
77
  end
64
78
 
79
+ def filter_type
80
+ self.class.filter_type if filterable?
81
+ end
82
+
83
+ private
84
+
85
+ # Only database columns can be filtered, and not encrypted ones since their
86
+ # stored values can't be compared. `filter: false` turns it off
87
+ def filterable?
88
+ options.fetch(:filter, true) && resource.model_column_names.include?(attribute_name.to_s) && !model.try(:encrypted_attributes)&.include?(attribute_name)
89
+ end
90
+
65
91
  ActiveSupport.run_load_hooks(:madmin_field, self)
66
92
  end
67
93
  end
@@ -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,8 +1,10 @@
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
- options.fetch(:searchable, model.column_names.include?(attribute_name.to_s))
7
+ options.fetch(:searchable, resource.model_column_names.include?(attribute_name.to_s))
6
8
  end
7
9
  end
8
10
  end
@@ -1,8 +1,10 @@
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
- options.fetch(:searchable, model.column_names.include?(attribute_name.to_s))
7
+ options.fetch(:searchable, resource.model_column_names.include?(attribute_name.to_s))
6
8
  end
7
9
  end
8
10
  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