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.
- checksums.yaml +4 -4
- data/README.md +89 -4
- data/app/assets/stylesheets/madmin/actiontext.css +31 -0
- data/app/assets/stylesheets/madmin/base.css +22 -11
- data/app/assets/stylesheets/madmin/buttons.css +9 -9
- data/app/assets/stylesheets/madmin/forms.css +271 -11
- data/app/assets/stylesheets/madmin/pagination.css +25 -21
- data/app/assets/stylesheets/madmin/reset.css +1 -1
- data/app/assets/stylesheets/madmin/sidebar.css +6 -6
- data/app/assets/stylesheets/madmin/tables.css +2 -1
- data/app/controllers/madmin/base_controller.rb +1 -5
- data/app/controllers/madmin/resource_controller.rb +53 -19
- data/app/helpers/madmin/application_helper.rb +33 -2
- data/app/helpers/madmin/filters_helper.rb +26 -0
- data/app/helpers/madmin/sort_helper.rb +1 -1
- data/app/javascript/madmin/application.js +4 -0
- data/app/javascript/madmin/controllers/application.js +0 -3
- data/app/javascript/madmin/controllers/filters_controller.js +35 -0
- data/app/javascript/madmin/controllers/nested_form_controller.js +5 -0
- data/app/javascript/madmin/controllers/select_controller.js +269 -22
- data/app/views/layouts/madmin/application.html.erb +1 -0
- data/app/views/madmin/application/_active_filters.html.erb +15 -0
- data/app/views/madmin/application/_filter.html.erb +13 -0
- data/app/views/madmin/application/_filters.html.erb +30 -0
- data/app/views/madmin/application/_javascript.html.erb +0 -2
- data/app/views/madmin/application/_pagination.html.erb +33 -0
- data/app/views/madmin/application/_per_page.html.erb +10 -0
- data/app/views/madmin/application/edit.html.erb +1 -1
- data/app/views/madmin/application/index.html.erb +9 -12
- data/app/views/madmin/application/new.html.erb +1 -1
- data/app/views/madmin/application/show.html.erb +2 -2
- data/app/views/madmin/fields/array/_form.html.erb +1 -0
- data/app/views/madmin/fields/array/_index.html.erb +1 -0
- data/app/views/madmin/fields/array/_show.html.erb +1 -0
- data/app/views/madmin/fields/attachment/_form.html.erb +1 -5
- data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
- data/app/views/madmin/fields/attachments/_form.html.erb +1 -1
- data/app/views/madmin/fields/belongs_to/_form.html.erb +1 -1
- data/app/views/madmin/fields/belongs_to/_index.html.erb +5 -2
- data/app/views/madmin/fields/belongs_to/_show.html.erb +1 -1
- data/app/views/madmin/fields/enum/_form.html.erb +1 -1
- data/app/views/madmin/fields/file/_form.html.erb +1 -1
- data/app/views/madmin/fields/has_many/_form.html.erb +1 -1
- data/app/views/madmin/fields/has_many/_show.html.erb +3 -11
- data/app/views/madmin/fields/has_one/_index.html.erb +5 -1
- data/app/views/madmin/fields/has_one/_show.html.erb +1 -1
- data/app/views/madmin/fields/json/_form.html.erb +1 -1
- data/app/views/madmin/fields/json/_show.html.erb +1 -1
- data/app/views/madmin/fields/nested_has_many/_show.html.erb +3 -11
- data/app/views/madmin/fields/nested_has_one/_form.html.erb +18 -0
- data/app/views/madmin/fields/polymorphic/_form.html.erb +1 -2
- data/app/views/madmin/fields/polymorphic/_index.html.erb +5 -1
- data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
- data/app/views/madmin/fields/select/_form.html.erb +1 -1
- data/config/importmap.rb +0 -3
- data/config/locales/el.yml +47 -0
- data/config/locales/en.yml +47 -0
- data/config/locales/fr.yml +47 -0
- data/config/locales/ko.yml +48 -0
- data/lib/generators/madmin/resource/templates/resource.rb.tt +3 -0
- data/lib/madmin/field.rb +26 -0
- data/lib/madmin/fields/array.rb +16 -0
- data/lib/madmin/fields/attachment.rb +7 -0
- data/lib/madmin/fields/belongs_to.rb +2 -1
- data/lib/madmin/fields/boolean.rb +1 -0
- data/lib/madmin/fields/currency.rb +7 -0
- data/lib/madmin/fields/date.rb +1 -0
- data/lib/madmin/fields/date_time.rb +1 -0
- data/lib/madmin/fields/decimal.rb +1 -0
- data/lib/madmin/fields/float.rb +1 -0
- data/lib/madmin/fields/has_many.rb +8 -23
- data/lib/madmin/fields/integer.rb +1 -0
- data/lib/madmin/fields/json.rb +21 -0
- data/lib/madmin/fields/nested_has_many.rb +2 -2
- data/lib/madmin/fields/nested_has_one.rb +48 -0
- data/lib/madmin/fields/polymorphic.rb +5 -0
- data/lib/madmin/fields/string.rb +2 -0
- data/lib/madmin/fields/text.rb +2 -0
- data/lib/madmin/filter.rb +113 -0
- data/lib/madmin/menu.rb +2 -1
- data/lib/madmin/page.rb +89 -0
- data/lib/madmin/pagination.rb +50 -0
- data/lib/madmin/resource.rb +86 -19
- data/lib/madmin/search.rb +5 -7
- data/lib/madmin/version.rb +1 -1
- data/lib/madmin.rb +45 -3
- metadata +17 -15
data/config/locales/ko.yml
CHANGED
|
@@ -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 +
|
|
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,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
|
data/lib/madmin/fields/date.rb
CHANGED
data/lib/madmin/fields/float.rb
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
data/lib/madmin/fields/json.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
data/lib/madmin/fields/string.rb
CHANGED
data/lib/madmin/fields/text.rb
CHANGED
|
@@ -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
data/lib/madmin/page.rb
ADDED
|
@@ -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
|