jav_madmin 2.6.7 → 3.3.2

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +89 -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 +271 -11
  7. data/app/assets/stylesheets/madmin/pagination.css +25 -21
  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 +2 -1
  11. data/app/controllers/madmin/base_controller.rb +1 -5
  12. data/app/controllers/madmin/resource_controller.rb +53 -19
  13. data/app/helpers/madmin/application_helper.rb +33 -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 +30 -0
  25. data/app/views/madmin/application/_javascript.html.erb +0 -2
  26. data/app/views/madmin/application/_pagination.html.erb +33 -0
  27. data/app/views/madmin/application/_per_page.html.erb +10 -0
  28. data/app/views/madmin/application/edit.html.erb +1 -1
  29. data/app/views/madmin/application/index.html.erb +9 -12
  30. data/app/views/madmin/application/new.html.erb +1 -1
  31. data/app/views/madmin/application/show.html.erb +2 -2
  32. data/app/views/madmin/fields/array/_form.html.erb +1 -0
  33. data/app/views/madmin/fields/array/_index.html.erb +1 -0
  34. data/app/views/madmin/fields/array/_show.html.erb +1 -0
  35. data/app/views/madmin/fields/attachment/_form.html.erb +1 -5
  36. data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
  37. data/app/views/madmin/fields/attachments/_form.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/_show.html.erb +3 -11
  45. data/app/views/madmin/fields/has_one/_index.html.erb +5 -1
  46. data/app/views/madmin/fields/has_one/_show.html.erb +1 -1
  47. data/app/views/madmin/fields/json/_form.html.erb +1 -1
  48. data/app/views/madmin/fields/json/_show.html.erb +1 -1
  49. data/app/views/madmin/fields/nested_has_many/_show.html.erb +3 -11
  50. data/app/views/madmin/fields/nested_has_one/_form.html.erb +18 -0
  51. data/app/views/madmin/fields/polymorphic/_form.html.erb +1 -2
  52. data/app/views/madmin/fields/polymorphic/_index.html.erb +5 -1
  53. data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
  54. data/app/views/madmin/fields/select/_form.html.erb +1 -1
  55. data/config/importmap.rb +0 -3
  56. data/config/locales/el.yml +47 -0
  57. data/config/locales/en.yml +47 -0
  58. data/config/locales/fr.yml +47 -0
  59. data/config/locales/ko.yml +48 -0
  60. data/lib/generators/madmin/resource/templates/resource.rb.tt +3 -0
  61. data/lib/madmin/field.rb +26 -0
  62. data/lib/madmin/fields/array.rb +16 -0
  63. data/lib/madmin/fields/attachment.rb +7 -0
  64. data/lib/madmin/fields/belongs_to.rb +2 -1
  65. data/lib/madmin/fields/boolean.rb +1 -0
  66. data/lib/madmin/fields/currency.rb +7 -0
  67. data/lib/madmin/fields/date.rb +1 -0
  68. data/lib/madmin/fields/date_time.rb +1 -0
  69. data/lib/madmin/fields/decimal.rb +1 -0
  70. data/lib/madmin/fields/float.rb +1 -0
  71. data/lib/madmin/fields/has_many.rb +8 -23
  72. data/lib/madmin/fields/integer.rb +1 -0
  73. data/lib/madmin/fields/json.rb +21 -0
  74. data/lib/madmin/fields/nested_has_many.rb +2 -2
  75. data/lib/madmin/fields/nested_has_one.rb +48 -0
  76. data/lib/madmin/fields/polymorphic.rb +5 -0
  77. data/lib/madmin/fields/string.rb +2 -0
  78. data/lib/madmin/fields/text.rb +2 -0
  79. data/lib/madmin/filter.rb +113 -0
  80. data/lib/madmin/menu.rb +2 -1
  81. data/lib/madmin/page.rb +89 -0
  82. data/lib/madmin/pagination.rb +50 -0
  83. data/lib/madmin/resource.rb +86 -19
  84. data/lib/madmin/search.rb +5 -7
  85. data/lib/madmin/version.rb +1 -1
  86. data/lib/madmin.rb +45 -3
  87. metadata +17 -15
@@ -26,6 +26,9 @@ ko:
26
26
  errors:
27
27
  one: 제출한 내용에 오류가 1개 있습니다
28
28
  other: 제출한 내용에 오류가 %{count}개 있습니다
29
+ flash:
30
+ destroy_failed: "%{name}을(를) 삭제할 수 없습니다"
31
+ destroy_restricted: "다른 레코드가 참조하고 있어 %{name}을(를) 삭제할 수 없습니다"
29
32
  confirmations:
30
33
  delete: 정말 삭제하시겠습니까?
31
34
  remove_with_changes: 정말 제거하시겠습니까? 다른 변경 사항도 함께 사라집니다.
@@ -38,3 +41,48 @@ ko:
38
41
  missing_resource:
39
42
  message: "%{resource}이(가) 존재하지 않습니다."
40
43
  create_hint: "다음 명령어를 실행하여 생성하세요:"
44
+ pagination:
45
+ label: 페이지 탐색
46
+ previous: 이전
47
+ next: 다음
48
+ info: "%{count}개 중 %{from}-%{to} 표시"
49
+ empty: 레코드가 없습니다
50
+ per_page: 페이지당
51
+ filters:
52
+ button: "필터"
53
+ heading: "다음 조건의 레코드 표시"
54
+ add: "+ 필터 추가"
55
+ apply: "적용"
56
+ clear_all: "필터 지우기"
57
+ remove: "필터 제거"
58
+ column: "열"
59
+ operator: "조건"
60
+ value: "값"
61
+ operators:
62
+ string:
63
+ contains: "포함"
64
+ eq: "일치"
65
+ starts_with: "시작"
66
+ blank: "비어 있음"
67
+ present: "비어 있지 않음"
68
+ number:
69
+ eq: "="
70
+ gt: ">"
71
+ gte: "≥"
72
+ lt: "<"
73
+ lte: "≤"
74
+ blank: "비어 있음"
75
+ date:
76
+ eq: "당일"
77
+ gte: "이후(당일 포함)"
78
+ lte: "이전(당일 포함)"
79
+ blank: "비어 있음"
80
+ datetime:
81
+ gte: "이후"
82
+ lte: "이전"
83
+ blank: "비어 있음"
84
+ boolean:
85
+ "true": "참"
86
+ "false": "거짓"
87
+ array:
88
+ includes: "포함"
@@ -26,6 +26,9 @@ class <%= class_name %>Resource < Madmin::Resource
26
26
  # Customize the display name of records in the admin area.
27
27
  # def self.display_name(record) = record.name
28
28
 
29
+ # Customize the confirmation shown before deleting a record. Return nil to skip it.
30
+ # def self.delete_confirmation(record) = "Delete #{record.name}? This can't be undone."
31
+
29
32
  # Customize the default sort column and direction.
30
33
  # def self.default_sort_column = "created_at"
31
34
  #
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,6 +33,16 @@ 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
47
  options[:label].presence || model.human_attribute_name(attribute_name)
34
48
  end
@@ -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,11 @@ 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
+ per_page = Madmin::Page.per_page_for(params["#{attribute_name}_per_page"])
42
+
43
+ paginate value(record), page: params[page_key], per_page: per_page, param: page_key
59
44
  end
60
45
  end
61
46
  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,113 @@
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
+ # Operators that match the value as text instead of casting it to the column's type
16
+ MATCHING_OPERATORS = %w[contains starts_with].freeze
17
+
18
+ attr_reader :field, :operator, :value
19
+
20
+ # Skips conditions for unknown columns or operators, and values that don't
21
+ # cast to the column's type
22
+ def self.from_params(resource, params)
23
+ params.map { |param| new(resource.get_attribute(param[:column].to_s.to_sym)&.field, param[:operator].to_s, param[:value].to_s) }.select(&:valid?)
24
+ end
25
+
26
+ # A new row in the filters form, for the first filterable column
27
+ def self.blank(resource)
28
+ field = resource.filterable_attributes.first.field
29
+ new(field, OPERATORS.fetch(field.filter_type).first, "")
30
+ end
31
+
32
+ def initialize(field, operator, value)
33
+ @field = field
34
+ @operator = operator
35
+ @value = value
36
+ end
37
+
38
+ def column
39
+ field.attribute_name.to_s
40
+ end
41
+
42
+ def type
43
+ field.filter_type
44
+ end
45
+
46
+ def value?
47
+ WITHOUT_VALUE.exclude?(operator)
48
+ end
49
+
50
+ def valid?
51
+ field&.filter_type.present? && OPERATORS[type].include?(operator) &&
52
+ (!value? || (matching_operator? ? value.present? : typed_value.present?))
53
+ end
54
+
55
+ def apply(scope)
56
+ attribute = scope.arel_table[column]
57
+
58
+ case operator
59
+ when "contains" then scope.where(as_text(attribute).matches("%#{escaped_value}%"))
60
+ when "starts_with" then scope.where(as_text(attribute).matches("#{escaped_value}%"))
61
+ when "eq" then scope.where(column => typed_value)
62
+ when "gt" then scope.where(attribute.gt(typed_value))
63
+ when "gte" then scope.where(attribute.gteq(typed_value))
64
+ when "lt" then scope.where(attribute.lt(typed_value))
65
+ when "lte" then scope.where(attribute.lteq(typed_value))
66
+ when "blank" then scope.where(column => blank_values)
67
+ when "present" then scope.where.not(column => blank_values)
68
+ when "true" then scope.where(column => true)
69
+ when "false" then scope.where(column => false)
70
+ when "includes" then scope.where(Arel::Nodes.build_quoted(typed_value).eq(Arel::Nodes::NamedFunction.new("ANY", [attribute])))
71
+ end
72
+ end
73
+
74
+ # The params for this filter, used to keep it in links
75
+ def to_h
76
+ {column: column, operator: operator, value: value}
77
+ end
78
+
79
+ # The value in the column's type, or its element type for arrays. Datetimes are parsed in Time.zone, like form input
80
+ def typed_value
81
+ @typed_value ||= value.presence && value_type.cast(value)
82
+ end
83
+
84
+ private
85
+
86
+ # Arrays filter by their elements
87
+ def value_type
88
+ attribute_type = field.model.type_for_attribute(column)
89
+ (type == :array) ? attribute_type.subtype : attribute_type
90
+ end
91
+
92
+ def matching_operator?
93
+ MATCHING_OPERATORS.include?(operator)
94
+ end
95
+
96
+ def uuid?
97
+ value_type.type == :uuid
98
+ end
99
+
100
+ def as_text(attribute)
101
+ uuid? ? Arel::Nodes::NamedFunction.new("CAST", [attribute.as("CHAR(36)")]) : attribute
102
+ end
103
+
104
+ def escaped_value
105
+ field.model.sanitize_sql_like(value)
106
+ end
107
+
108
+ def blank_values
109
+ # UUIDs cast "" to NULL, which would match nothing
110
+ (type == :string && !uuid?) ? [nil, ""] : nil
111
+ end
112
+ end
113
+ 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,89 @@
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
+ PER_PAGE_OPTIONS = [20, 50, 100, 200].freeze
9
+
10
+ attr_reader :count, :page, :per_page, :param
11
+
12
+ # The page size for a request param. Invalid sizes fall back to the default
13
+ # and large ones are capped. Sizes passed to Page.new directly aren't capped
14
+ def self.per_page_for(param)
15
+ size = param.to_s.to_i
16
+ size.positive? ? [size, max_per_page].min : Madmin.per_page
17
+ end
18
+
19
+ def self.max_per_page
20
+ [PER_PAGE_OPTIONS.max, Madmin.per_page].max
21
+ end
22
+ private_class_method :max_per_page
23
+
24
+ def initialize(count:, page: 1, per_page: Madmin.per_page, param: :page)
25
+ @count = count.to_i
26
+ @per_page = [per_page.to_i, 1].max
27
+ @page = [page.to_i, 1].max
28
+ @param = param.to_s
29
+ end
30
+
31
+ # The param carrying the page size: "per_page", or "posts_per_page" for a "posts_page" param
32
+ def per_page_param
33
+ "#{param.delete_suffix("page")}per_page"
34
+ end
35
+
36
+ # Page sizes to offer, always including the default and the current one
37
+ def per_page_options
38
+ (PER_PAGE_OPTIONS | [Madmin.per_page, per_page]).sort
39
+ end
40
+
41
+ # Whether a different page size would change what's shown
42
+ def resizable?
43
+ count > per_page_options.min
44
+ end
45
+
46
+ def last
47
+ [(count.to_f / per_page).ceil, 1].max
48
+ end
49
+
50
+ # Pages past the end are served empty rather than raising or clamping
51
+ def overflow?
52
+ page > last
53
+ end
54
+
55
+ def offset
56
+ (page - 1) * per_page
57
+ end
58
+
59
+ def from
60
+ (count.zero? || overflow?) ? 0 : offset + 1
61
+ end
62
+
63
+ def to
64
+ overflow? ? 0 : [offset + per_page, count].min
65
+ end
66
+
67
+ def prev
68
+ [page - 1, last].min if page > 1
69
+ end
70
+
71
+ def next
72
+ page + 1 if page < last
73
+ end
74
+
75
+ # Page numbers to link to, with :gap where pages are skipped
76
+ # [1, :gap, 9, 10, 11, :gap, 20]
77
+ def series(slots: SERIES_SLOTS)
78
+ return (1..last).to_a if last <= slots
79
+
80
+ start = (page - (slots - 1) / 2).clamp(1, last - slots + 1)
81
+ pages = (start...(start + slots)).to_a
82
+ pages[0] = 1
83
+ pages[1] = :gap unless pages[1] == 2
84
+ pages[-1] = last
85
+ pages[-2] = :gap unless pages[-2] == last - 1
86
+ pages
87
+ end
88
+ end
89
+ end