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.
- checksums.yaml +4 -4
- data/README.md +121 -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 +263 -7
- data/app/assets/stylesheets/madmin/pagination.css +10 -23
- 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 +5 -1
- data/app/controllers/madmin/base_controller.rb +1 -5
- data/app/controllers/madmin/resource_controller.rb +49 -16
- data/app/helpers/madmin/application_helper.rb +15 -2
- data/app/helpers/madmin/filters_helper.rb +26 -0
- data/app/helpers/madmin/sort_helper.rb +2 -2
- 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 +32 -0
- data/app/views/madmin/application/_javascript.html.erb +0 -2
- data/app/views/madmin/application/_pagination.html.erb +32 -0
- data/app/views/madmin/application/edit.html.erb +1 -1
- data/app/views/madmin/application/index.html.erb +18 -15
- data/app/views/madmin/application/new.html.erb +1 -1
- data/app/views/madmin/application/show.html.erb +5 -3
- 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 +0 -4
- data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
- data/app/views/madmin/fields/attachments/_index.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/has_many/_form.html.erb +1 -1
- data/app/views/madmin/fields/has_many/_index.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/_index.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 -1
- data/config/locales/el.yml +86 -0
- data/config/locales/en.yml +46 -0
- data/config/locales/fr.yml +46 -0
- data/config/locales/ko.yml +84 -0
- data/lib/madmin/field.rb +27 -1
- 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 +6 -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 +3 -1
- data/lib/madmin/fields/text.rb +3 -1
- data/lib/madmin/filter.rb +96 -0
- data/lib/madmin/menu.rb +2 -1
- data/lib/madmin/page.rb +61 -0
- data/lib/madmin/pagination.rb +50 -0
- data/lib/madmin/resource.rb +95 -7
- data/lib/madmin/resource_builder.rb +8 -0
- data/lib/madmin/search.rb +5 -7
- data/lib/madmin/version.rb +1 -1
- data/lib/madmin.rb +47 -2
- 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
|
|
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 +
|
|
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,9 @@ module Madmin
|
|
|
33
36
|
true
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
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
|
@@ -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,
|
|
7
|
+
options.fetch(:searchable, resource.model_column_names.include?(attribute_name.to_s))
|
|
6
8
|
end
|
|
7
9
|
end
|
|
8
10
|
end
|
data/lib/madmin/fields/text.rb
CHANGED
|
@@ -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,
|
|
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
data/lib/madmin/page.rb
ADDED
|
@@ -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
|