madmin 1.0.1 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
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