madmin 1.0.1 → 1.2.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -1
  3. data/app/controllers/madmin/base_controller.rb +0 -5
  4. data/app/controllers/madmin/resource_controller.rb +23 -2
  5. data/app/helpers/madmin/application_helper.rb +4 -0
  6. data/app/helpers/madmin/nav_helper.rb +30 -0
  7. data/app/helpers/madmin/sort_helper.rb +32 -0
  8. data/app/views/layouts/madmin/application.html.erb +5 -5
  9. data/app/views/madmin/application/_form.html.erb +6 -8
  10. data/app/views/madmin/application/_javascript.html.erb +73 -7
  11. data/app/views/madmin/application/_navigation.html.erb +31 -5
  12. data/app/views/madmin/application/edit.html.erb +5 -1
  13. data/app/views/madmin/application/index.html.erb +34 -22
  14. data/app/views/madmin/application/new.html.erb +5 -1
  15. data/app/views/madmin/application/show.html.erb +24 -17
  16. data/app/views/madmin/fields/attachment/_form.html.erb +3 -1
  17. data/app/views/madmin/fields/attachment/_show.html.erb +7 -1
  18. data/app/views/madmin/fields/attachments/_form.html.erb +3 -1
  19. data/app/views/madmin/fields/attachments/_show.html.erb +7 -3
  20. data/app/views/madmin/fields/belongs_to/_form.html.erb +4 -2
  21. data/app/views/madmin/fields/belongs_to/_show.html.erb +1 -1
  22. data/app/views/madmin/fields/boolean/_form.html.erb +3 -1
  23. data/app/views/madmin/fields/date/_form.html.erb +3 -1
  24. data/app/views/madmin/fields/date_time/_form.html.erb +3 -1
  25. data/app/views/madmin/fields/decimal/_form.html.erb +3 -1
  26. data/app/views/madmin/fields/enum/_form.html.erb +4 -2
  27. data/app/views/madmin/fields/float/_form.html.erb +3 -1
  28. data/app/views/madmin/fields/has_many/_form.html.erb +4 -2
  29. data/app/views/madmin/fields/has_many/_show.html.erb +1 -1
  30. data/app/views/madmin/fields/has_one/_form.html.erb +3 -2
  31. data/app/views/madmin/fields/integer/_form.html.erb +3 -1
  32. data/app/views/madmin/fields/json/_form.html.erb +3 -1
  33. data/app/views/madmin/fields/nested_has_many/_fields.html.erb +18 -0
  34. data/app/views/madmin/fields/nested_has_many/_form.html.erb +32 -0
  35. data/app/views/madmin/fields/nested_has_many/_index.html.erb +1 -0
  36. data/app/views/madmin/fields/nested_has_many/_show.html.erb +5 -0
  37. data/app/views/madmin/fields/password/_form.html.erb +4 -0
  38. data/app/views/madmin/fields/password/_index.html.erb +1 -0
  39. data/app/views/madmin/fields/password/_show.html.erb +1 -0
  40. data/app/views/madmin/fields/polymorphic/_form.html.erb +3 -1
  41. data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
  42. data/app/views/madmin/fields/rich_text/_form.html.erb +3 -1
  43. data/app/views/madmin/fields/string/_form.html.erb +3 -1
  44. data/app/views/madmin/fields/text/_form.html.erb +3 -1
  45. data/app/views/madmin/fields/time/_form.html.erb +3 -1
  46. data/app/views/madmin/shared/_label.html.erb +4 -0
  47. data/lib/generators/madmin/field/field_generator.rb +31 -0
  48. data/lib/generators/madmin/field/templates/_form.html.erb +2 -0
  49. data/lib/generators/madmin/field/templates/_index.html.erb +1 -0
  50. data/lib/generators/madmin/field/templates/_show.html.erb +1 -0
  51. data/lib/generators/madmin/field/templates/field.rb.tt +26 -0
  52. data/lib/generators/madmin/install/install_generator.rb +6 -1
  53. data/lib/generators/madmin/install/templates/routes.rb.tt +3 -0
  54. data/lib/generators/madmin/resource/resource_generator.rb +15 -4
  55. data/lib/generators/madmin/resource/templates/controller.rb.tt +6 -0
  56. data/lib/generators/madmin/resource/templates/resource.rb.tt +14 -0
  57. data/lib/generators/madmin/views/javascript_generator.rb +15 -0
  58. data/lib/generators/madmin/views/views_generator.rb +6 -5
  59. data/lib/madmin/engine.rb +1 -1
  60. data/lib/madmin/field.rb +4 -0
  61. data/lib/madmin/fields/belongs_to.rb +9 -5
  62. data/lib/madmin/fields/has_many.rb +10 -5
  63. data/lib/madmin/fields/nested_has_many.rb +40 -0
  64. data/lib/madmin/fields/password.rb +6 -0
  65. data/lib/madmin/fields/string.rb +3 -0
  66. data/lib/madmin/fields/text.rb +3 -0
  67. data/lib/madmin/generator_helpers.rb +22 -4
  68. data/lib/madmin/resource.rb +53 -19
  69. data/lib/madmin/search.rb +60 -0
  70. data/lib/madmin/version.rb +1 -1
  71. data/lib/madmin.rb +30 -14
  72. metadata +25 -5
@@ -1,4 +1,10 @@
1
1
  module Madmin
2
2
  class <%= class_name.pluralize %>Controller < Madmin::ResourceController
3
+ <% if class_name == "ActiveStorage::Blob" -%>
4
+ def new
5
+ super
6
+ @record.assign_attributes(filename: "")
7
+ end
8
+ <% end -%>
3
9
  end
4
10
  end
@@ -8,4 +8,18 @@ class <%= class_name %>Resource < Madmin::Resource
8
8
  <% associations.each do |association_name| -%>
9
9
  attribute :<%= association_name %>
10
10
  <% end -%>
11
+
12
+ # Uncomment this to customize the display name of records in the admin area.
13
+ # def self.display_name(record)
14
+ # record.name
15
+ # end
16
+
17
+ # Uncomment this to customize the default sort column and direction.
18
+ # def self.default_sort_column
19
+ # "created_at"
20
+ # end
21
+ #
22
+ # def self.default_sort_direction
23
+ # "desc"
24
+ # end
11
25
  end
@@ -0,0 +1,15 @@
1
+ require "madmin/view_generator"
2
+
3
+ module Madmin
4
+ module Generators
5
+ module Views
6
+ class JavascriptGenerator < Madmin::ViewGenerator
7
+ source_root template_source_path
8
+
9
+ def copy_navigation
10
+ copy_resource_template("_javascript")
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -4,11 +4,12 @@ module Madmin
4
4
  module Generators
5
5
  class ViewsGenerator < Madmin::ViewGenerator
6
6
  def copy_templates
7
- view = "madmin:views:"
8
- call_generator("#{view}index", resource_path, "--namespace", namespace)
9
- call_generator("#{view}show", resource_path, "--namespace", namespace)
10
- call_generator("#{view}new", resource_path, "--namespace", namespace)
11
- call_generator("#{view}edit", resource_path, "--namespace", namespace)
7
+ # Some generators duplicate templates, so not everything is present here
8
+ call_generator("madmin:views:edit", resource_path, "--namespace", namespace)
9
+ call_generator("madmin:views:index", resource_path, "--namespace", namespace)
10
+ call_generator("madmin:views:layout", resource_path, "--namespace", namespace)
11
+ call_generator("madmin:views:new", resource_path, "--namespace", namespace)
12
+ call_generator("madmin:views:show", resource_path, "--namespace", namespace)
12
13
  end
13
14
  end
14
15
  end
data/lib/madmin/engine.rb CHANGED
@@ -6,7 +6,7 @@ module Madmin
6
6
  end
7
7
 
8
8
  config.to_prepare do
9
- Madmin.resources = []
9
+ Madmin.reset_resources!
10
10
  end
11
11
  end
12
12
  end
data/lib/madmin/field.rb CHANGED
@@ -36,5 +36,9 @@ module Madmin
36
36
  def required?
37
37
  model.validators_on(attribute_name).any? { |v| v.is_a? ActiveModel::Validations::PresenceValidator }
38
38
  end
39
+
40
+ def searchable?
41
+ false
42
+ end
39
43
  end
40
44
  end
@@ -2,17 +2,21 @@ module Madmin
2
2
  module Fields
3
3
  class BelongsTo < Field
4
4
  def options_for_select(record)
5
- association = record.class.reflect_on_association(attribute_name)
6
-
7
- klass = association.klass
8
- klass.all.map do |r|
9
- ["#{klass.name} ##{r.id}", r.id]
5
+ if (record = record.send(attribute_name))
6
+ resource = Madmin.resource_for(record)
7
+ [[resource.display_name(record), record.id]]
8
+ else
9
+ []
10
10
  end
11
11
  end
12
12
 
13
13
  def to_param
14
14
  "#{attribute_name}_id"
15
15
  end
16
+
17
+ def index_path
18
+ Madmin.resource_by_name(model.reflect_on_association(attribute_name).klass).index_path(format: :json)
19
+ end
16
20
  end
17
21
  end
18
22
  end
@@ -2,17 +2,22 @@ module Madmin
2
2
  module Fields
3
3
  class HasMany < Field
4
4
  def options_for_select(record)
5
- association = record.class.reflect_on_association(attribute_name)
6
-
7
- klass = association.klass
8
- klass.all.map do |r|
9
- ["#{klass.name} ##{r.id}", r.id]
5
+ if (records = record.send(attribute_name))
6
+ return [] unless records.first
7
+ resource = Madmin.resource_for(records.first)
8
+ records.map { |record| [resource.display_name(record), record.id] }
9
+ else
10
+ []
10
11
  end
11
12
  end
12
13
 
13
14
  def to_param
14
15
  {"#{attribute_name.to_s.singularize}_ids".to_sym => []}
15
16
  end
17
+
18
+ def index_path
19
+ Madmin.resource_by_name(model.reflect_on_association(attribute_name).klass).index_path(format: :json)
20
+ end
16
21
  end
17
22
  end
18
23
  end
@@ -0,0 +1,40 @@
1
+ module Madmin
2
+ module Fields
3
+ class NestedHasMany < Field
4
+ DEFAULT_ATTRIBUTES = %w[_destroy id].freeze
5
+ def nested_attributes
6
+ resource.attributes.reject { |i| skipped_fields.include?(i[:name]) }
7
+ end
8
+
9
+ def resource
10
+ "#{to_model.name}Resource".constantize
11
+ end
12
+
13
+ def to_param
14
+ {"#{attribute_name}_attributes": permitted_fields}
15
+ end
16
+
17
+ def to_partial_path(name)
18
+ unless %w[index show form fields].include? name
19
+ raise ArgumentError, "`partial` must be 'index', 'show', 'form' or 'fields'"
20
+ end
21
+
22
+ "/madmin/fields/#{self.class.field_type}/#{name}"
23
+ end
24
+
25
+ def to_model
26
+ attribute_name.to_s.singularize.classify.constantize
27
+ end
28
+
29
+ private
30
+
31
+ def permitted_fields
32
+ (resource.permitted_params - skipped_fields + DEFAULT_ATTRIBUTES).uniq
33
+ end
34
+
35
+ def skipped_fields
36
+ options[:skip] || []
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,6 @@
1
+ module Madmin
2
+ module Fields
3
+ class Password < Field
4
+ end
5
+ end
6
+ end
@@ -1,6 +1,9 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class String < Field
4
+ def searchable?
5
+ options.fetch(:searchable, model.column_names.include?(attribute_name.to_s))
6
+ end
4
7
  end
5
8
  end
6
9
  end
@@ -1,6 +1,9 @@
1
1
  module Madmin
2
2
  module Fields
3
3
  class Text < Field
4
+ def searchable?
5
+ options.fetch(:searchable, model.column_names.include?(attribute_name.to_s))
6
+ end
4
7
  end
5
8
  end
6
9
  end
@@ -1,24 +1,30 @@
1
1
  module Madmin
2
2
  module GeneratorHelpers
3
+ ROUTES_FILE = {default: "config/routes.rb", separated: "config/routes/madmin.rb"}.freeze
4
+
3
5
  def call_generator(generator, *args)
4
6
  Rails::Generators.invoke(generator, args, generator_options)
5
7
  end
6
8
 
7
9
  def route_namespace_exists?
8
- File.readlines(Rails.root.join("config/routes.rb")).grep(/namespace :madmin/).size > 0
10
+ File.readlines(Rails.root.join(default_routes_file)).grep(/namespace :madmin/).size > 0
11
+ end
12
+
13
+ def rails6_1_and_up?
14
+ Gem.loaded_specs["rails"].version >= Gem::Version.new(6.1)
9
15
  end
10
16
 
11
17
  # Method copied from Rails 6.1 master
12
- def route(routing_code, namespace: nil, sentinel: nil, indentation: 2)
18
+ def route(routing_code, namespace: nil, sentinel: nil, indentation: 2, file: default_routes_file)
13
19
  routing_code = Array(namespace).reverse.reduce(routing_code) { |code, ns|
14
20
  "namespace :#{ns} do\n#{indent(code, 2)}\nend"
15
21
  }
16
22
 
17
23
  log :route, routing_code
18
- sentinel ||= /\.routes\.draw do\s*\n/m
24
+ sentinel ||= default_sentinel(file)
19
25
 
20
26
  in_root do
21
- inject_into_file "config/routes.rb", optimize_indentation(routing_code, indentation), after: sentinel, verbose: false, force: false
27
+ inject_into_file file, optimize_indentation(routing_code, indentation), after: sentinel, verbose: false, force: false
22
28
  end
23
29
  end
24
30
 
@@ -30,6 +36,18 @@ module Madmin
30
36
 
31
37
  private
32
38
 
39
+ def separated_routes_file?
40
+ default_routes_file.eql?(ROUTES_FILE[:separated])
41
+ end
42
+
43
+ def default_sentinel(file)
44
+ file.eql?(ROUTES_FILE[:default]) ? /\.routes\.draw do\s*\n/m : /namespace :madmin do\s*\n/m
45
+ end
46
+
47
+ def default_routes_file
48
+ rails6_1_and_up? ? ROUTES_FILE[:separated] : ROUTES_FILE[:default]
49
+ end
50
+
33
51
  def generator_options
34
52
  {behavior: behavior}
35
53
  end
@@ -1,14 +1,10 @@
1
1
  module Madmin
2
2
  class Resource
3
- class_attribute :attributes, default: []
3
+ class_attribute :attributes, default: ActiveSupport::OrderedHash.new
4
4
  class_attribute :scopes, default: []
5
5
 
6
6
  class << self
7
7
  def inherited(base)
8
- # Remove any old references
9
- Madmin.resources.delete(base)
10
- Madmin.resources << base
11
-
12
8
  base.attributes = attributes.dup
13
9
  base.scopes = scopes.dup
14
10
  super
@@ -18,6 +14,10 @@ module Madmin
18
14
  model_name.constantize
19
15
  end
20
16
 
17
+ def model_find(id)
18
+ friendly_model? ? model.friendly.find(id) : model.find(id)
19
+ end
20
+
21
21
  def model_name
22
22
  to_s.chomp("Resource").classify
23
23
  end
@@ -26,11 +26,19 @@ module Madmin
26
26
  scopes << name
27
27
  end
28
28
 
29
+ def get_attribute(name)
30
+ attributes[name]
31
+ end
32
+
29
33
  def attribute(name, type = nil, **options)
30
- attributes << {
34
+ type ||= infer_type(name)
35
+ field = options[:field] || field_for_type(type)
36
+
37
+ attributes[name] = OpenStruct.new(
31
38
  name: name,
32
- field: field_for_type(name, type).new(**options.merge(attribute_name: name, model: model))
33
- }
39
+ type: type,
40
+ field: field.new(**options.merge(attribute_name: name, model: model))
41
+ )
34
42
  end
35
43
 
36
44
  def friendly_name
@@ -38,21 +46,27 @@ module Madmin
38
46
  end
39
47
 
40
48
  def index_path(options = {})
41
- path = "/madmin/#{model.model_name.collection}"
42
- path += "?#{options.to_param}" if options.any?
43
- path
49
+ route_name = "madmin_#{model.model_name.plural}_path"
50
+
51
+ url_helpers.send(route_name, options)
44
52
  end
45
53
 
46
54
  def new_path
47
- "/madmin/#{model.model_name.collection}/new"
55
+ route_name = "new_madmin_#{model.model_name.singular}_path"
56
+
57
+ url_helpers.send(route_name)
48
58
  end
49
59
 
50
60
  def show_path(record)
51
- "/madmin/#{model.model_name.collection}/#{record.id}"
61
+ route_name = "madmin_#{model.model_name.singular}_path"
62
+
63
+ url_helpers.send(route_name, record.to_param)
52
64
  end
53
65
 
54
66
  def edit_path(record)
55
- "/madmin/#{model.model_name.collection}/#{record.id}/edit"
67
+ route_name = "edit_madmin_#{model.model_name.singular}_path"
68
+
69
+ url_helpers.send(route_name, record.to_param)
56
70
  end
57
71
 
58
72
  def param_key
@@ -60,18 +74,28 @@ module Madmin
60
74
  end
61
75
 
62
76
  def permitted_params
63
- attributes.map { |a| a[:field].to_param }
77
+ attributes.values.filter_map { |a| a.field.to_param if a.field.visible?(:form) }
64
78
  end
65
79
 
66
80
  def display_name(record)
67
81
  "#{record.class} ##{record.id}"
68
82
  end
69
83
 
70
- private
84
+ def friendly_model?
85
+ model.respond_to? :friendly
86
+ end
71
87
 
72
- def field_for_type(name, type)
73
- type ||= infer_type(name)
88
+ def sortable_columns
89
+ model.column_names
90
+ end
91
+
92
+ def searchable_attributes
93
+ attributes.values.select { |a| a.field.searchable? }
94
+ end
74
95
 
96
+ private
97
+
98
+ def field_for_type(type)
75
99
  {
76
100
  binary: Fields::String,
77
101
  blob: Fields::Text,
@@ -90,6 +114,7 @@ module Madmin
90
114
  text: Fields::Text,
91
115
  time: Fields::Time,
92
116
  timestamp: Fields::Time,
117
+ password: Fields::Password,
93
118
 
94
119
  # Postgres specific types
95
120
  bit: Fields::String,
@@ -126,7 +151,8 @@ module Madmin
126
151
  polymorphic: Fields::Polymorphic,
127
152
  has_many: Fields::HasMany,
128
153
  has_one: Fields::HasOne,
129
- rich_text: Fields::RichText
154
+ rich_text: Fields::RichText,
155
+ nested_has_many: Fields::NestedHasMany
130
156
  }.fetch(type)
131
157
  rescue
132
158
  raise ArgumentError, <<~MESSAGE
@@ -157,6 +183,10 @@ module Madmin
157
183
  :attachment
158
184
  elsif model.reflect_on_association(:"#{name_string}_attachments")
159
185
  :attachments
186
+
187
+ # has_secure_password
188
+ elsif model.attribute_types.include?("#{name_string}_digest") || name_string.ends_with?("_confirmation")
189
+ :password
160
190
  end
161
191
  end
162
192
 
@@ -171,6 +201,10 @@ module Madmin
171
201
  :belongs_to
172
202
  end
173
203
  end
204
+
205
+ def url_helpers
206
+ @url_helpers ||= Rails.application.routes.url_helpers
207
+ end
174
208
  end
175
209
  end
176
210
  end
@@ -0,0 +1,60 @@
1
+ # based on Administrate Search: https://github.com/thoughtbot/administrate/blob/main/lib/administrate/search.rb
2
+
3
+ module Madmin
4
+ class Search
5
+ attr_reader :query
6
+
7
+ def initialize(scoped_resource, resource, term)
8
+ @resource = resource
9
+ @scoped_resource = scoped_resource
10
+ @query = term
11
+ end
12
+
13
+ def run
14
+ if query.blank?
15
+ @scoped_resource.all
16
+ else
17
+ search_results(@scoped_resource)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def search_results(resources)
24
+ resources.where(query_template, *query_values)
25
+ end
26
+
27
+ def query_template
28
+ search_attributes.map do |attr|
29
+ table_name = query_table_name(attr)
30
+ searchable_fields(attr).map do |field|
31
+ column_name = column_to_query(field)
32
+ "LOWER(CAST(#{table_name}.#{column_name} AS CHAR(256))) LIKE ?"
33
+ end.join(" OR ")
34
+ end.join(" OR ")
35
+ end
36
+
37
+ def searchable_fields(attr)
38
+ [attr[:name]]
39
+ end
40
+
41
+ def query_values
42
+ fields_count = search_attributes.sum do |attr|
43
+ searchable_fields(attr).count
44
+ end
45
+ ["%#{@query.mb_chars.downcase}%"] * fields_count
46
+ end
47
+
48
+ def search_attributes
49
+ @resource.searchable_attributes
50
+ end
51
+
52
+ def query_table_name(attr)
53
+ ActiveRecord::Base.connection.quote_column_name(@scoped_resource.table_name)
54
+ end
55
+
56
+ def column_to_query(attr)
57
+ ActiveRecord::Base.connection.quote_column_name(attr)
58
+ end
59
+ end
60
+ end