administratum 0.2.4

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 (88) hide show
  1. checksums.yaml +7 -0
  2. data/DESIGN.md +68 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +431 -0
  5. data/Rakefile +6 -0
  6. data/app/admin/administratum/user_admin.rb +39 -0
  7. data/app/assets/builds/administratum.css +2 -0
  8. data/app/assets/javascripts/administratum.js +250 -0
  9. data/app/assets/tailwind/administratum/components.css +199 -0
  10. data/app/assets/tailwind/administratum/index.css +8 -0
  11. data/app/assets/tailwind/administratum/theme.css +62 -0
  12. data/app/controllers/administratum/application_controller.rb +31 -0
  13. data/app/controllers/administratum/dashboard_controller.rb +7 -0
  14. data/app/controllers/administratum/page.rb +51 -0
  15. data/app/controllers/administratum/password_resets_controller.rb +42 -0
  16. data/app/controllers/administratum/resource.rb +224 -0
  17. data/app/controllers/administratum/sessions_controller.rb +33 -0
  18. data/app/helpers/administratum/application_helper.rb +11 -0
  19. data/app/helpers/administratum/form_helper.rb +115 -0
  20. data/app/helpers/administratum/island_helper.rb +16 -0
  21. data/app/helpers/administratum/layout_helper.rb +81 -0
  22. data/app/helpers/administratum/resource_helper.rb +199 -0
  23. data/app/mailers/administratum/application_mailer.rb +8 -0
  24. data/app/mailers/administratum/user_mailer.rb +11 -0
  25. data/app/models/administratum/application_record.rb +7 -0
  26. data/app/models/administratum/user.rb +13 -0
  27. data/app/views/administratum/dashboard/index.html.erb +16 -0
  28. data/app/views/administratum/fields/_belongs_to.html.erb +1 -0
  29. data/app/views/administratum/fields/_boolean.html.erb +1 -0
  30. data/app/views/administratum/fields/_date.html.erb +1 -0
  31. data/app/views/administratum/fields/_datetime.html.erb +1 -0
  32. data/app/views/administratum/fields/_email.html.erb +1 -0
  33. data/app/views/administratum/fields/_has_many_ids.html.erb +1 -0
  34. data/app/views/administratum/fields/_hidden.html.erb +1 -0
  35. data/app/views/administratum/fields/_island.html.erb +4 -0
  36. data/app/views/administratum/fields/_json.html.erb +1 -0
  37. data/app/views/administratum/fields/_multi_select.html.erb +1 -0
  38. data/app/views/administratum/fields/_number.html.erb +1 -0
  39. data/app/views/administratum/fields/_password.html.erb +1 -0
  40. data/app/views/administratum/fields/_select.html.erb +1 -0
  41. data/app/views/administratum/fields/_string.html.erb +1 -0
  42. data/app/views/administratum/fields/_textarea.html.erb +1 -0
  43. data/app/views/administratum/fields/_wrapper.html.erb +11 -0
  44. data/app/views/administratum/pages/index.html.erb +7 -0
  45. data/app/views/administratum/password_resets/edit.html.erb +17 -0
  46. data/app/views/administratum/password_resets/new.html.erb +11 -0
  47. data/app/views/administratum/resources/_attributes.html.erb +11 -0
  48. data/app/views/administratum/resources/_form.html.erb +14 -0
  49. data/app/views/administratum/resources/_table.html.erb +42 -0
  50. data/app/views/administratum/resources/edit.html.erb +7 -0
  51. data/app/views/administratum/resources/index.html.erb +17 -0
  52. data/app/views/administratum/resources/new.html.erb +7 -0
  53. data/app/views/administratum/resources/show.html.erb +7 -0
  54. data/app/views/administratum/sessions/new.html.erb +18 -0
  55. data/app/views/administratum/shared/_card.html.erb +9 -0
  56. data/app/views/administratum/shared/_filters.html.erb +43 -0
  57. data/app/views/administratum/shared/_flash.html.erb +9 -0
  58. data/app/views/administratum/shared/_form_errors.html.erb +10 -0
  59. data/app/views/administratum/shared/_nav.html.erb +10 -0
  60. data/app/views/administratum/shared/_page_header.html.erb +17 -0
  61. data/app/views/administratum/shared/_panel.html.erb +24 -0
  62. data/app/views/administratum/shared/_topbar.html.erb +29 -0
  63. data/app/views/administratum/user_mailer/reset_password.html.erb +4 -0
  64. data/app/views/administratum/user_mailer/reset_password.text.erb +7 -0
  65. data/app/views/layouts/administratum/application.html.erb +25 -0
  66. data/app/views/layouts/administratum/authentication.html.erb +32 -0
  67. data/config/locales/en.yml +65 -0
  68. data/config/routes.rb +16 -0
  69. data/db/migrate/20261016000000_create_administratum_users.rb +17 -0
  70. data/lib/administratum/configuration.rb +66 -0
  71. data/lib/administratum/definition.rb +283 -0
  72. data/lib/administratum/dsl.rb +195 -0
  73. data/lib/administratum/engine.rb +31 -0
  74. data/lib/administratum/field_types.rb +77 -0
  75. data/lib/administratum/page_dsl.rb +48 -0
  76. data/lib/administratum/paths.rb +85 -0
  77. data/lib/administratum/ransackable.rb +41 -0
  78. data/lib/administratum/registry.rb +95 -0
  79. data/lib/administratum/routing.rb +57 -0
  80. data/lib/administratum/tailwind.rb +34 -0
  81. data/lib/administratum/version.rb +5 -0
  82. data/lib/administratum.rb +43 -0
  83. data/lib/generators/administratum/install/install_generator.rb +42 -0
  84. data/lib/generators/administratum/install/templates/administratum.rb +41 -0
  85. data/lib/generators/administratum/resource/resource_generator.rb +30 -0
  86. data/lib/generators/administratum/resource/templates/resource.rb.tt +20 -0
  87. data/lib/tasks/administratum.rake +32 -0
  88. metadata +209 -0
data/config/routes.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ Administratum::Engine.routes.draw do
4
+ # Registers the Devise/Warden scope only; the engine has its own controllers.
5
+ devise_for :users, class_name: "Administratum::User", singular: :administratum_user, module: :devise,
6
+ router_name: :administratum, skip: :all
7
+
8
+ get "sign_in", to: "sessions#new", as: :sign_in
9
+ post "sign_in", to: "sessions#create"
10
+ delete "sign_out", to: "sessions#destroy", as: :sign_out
11
+ resource :password_reset, only: %i[new create edit update]
12
+
13
+ Administratum::Routing.draw(self)
14
+
15
+ root to: "dashboard#index"
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdministratumUsers < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :administratum_users do |t|
6
+ t.string :email, null: false, default: ""
7
+ t.string :encrypted_password, null: false, default: ""
8
+ t.string :reset_password_token
9
+ t.datetime :reset_password_sent_at
10
+ t.datetime :remember_created_at
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :administratum_users, :email, unique: true
15
+ add_index :administratum_users, :reset_password_token, unique: true
16
+ end
17
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Global settings, set from config/initializers/administratum.rb.
5
+ class Configuration
6
+ STYLESHEET_MODES = %i[bundled host].freeze
7
+
8
+ # Name shown in the top bar and the page title.
9
+ attr_accessor :app_name
10
+ # Path or URL to a logo image. Shown instead of the app name when set.
11
+ attr_accessor :logo
12
+ # Controller the engine's ApplicationController inherits from, as a string.
13
+ attr_accessor :parent_controller
14
+ # Partial rendered on the dashboard. nil renders the built-in resource overview.
15
+ attr_accessor :dashboard
16
+ # Sender address for password reset emails.
17
+ attr_accessor :mailer_sender
18
+ # Default page size on index pages.
19
+ attr_accessor :per_page
20
+ # Whether Administratum::UserAdmin is mounted, so admins can manage admin users.
21
+ attr_accessor :manage_users
22
+ # A lambda receiving the view context, returning extra tags for <head>.
23
+ attr_accessor :head
24
+ # A partial rendered inside <head>, alternative to `head`.
25
+ attr_accessor :head_partial
26
+ # Extra helper modules made available in every admin view.
27
+ attr_accessor :helpers
28
+ # Directories scanned for resource definitions, relative to Rails.root.
29
+ attr_accessor :resource_paths
30
+ # View directory for resource templates: app/views/<view_prefix>/<route_key>.
31
+ attr_accessor :view_prefix
32
+ # Token overrides, e.g. { primary: "#7c3aed" } becomes --color-adm-primary.
33
+ attr_reader :theme, :dark_theme
34
+ attr_reader :stylesheet, :field_types
35
+
36
+ def initialize
37
+ @app_name = "Administratum"
38
+ @parent_controller = "ActionController::Base"
39
+ @mailer_sender = "admin@example.com"
40
+ @per_page = 25
41
+ @manage_users = true
42
+ @helpers = []
43
+ @resource_paths = ["app/admin"]
44
+ @view_prefix = "admin"
45
+ @theme = {}
46
+ @dark_theme = {}
47
+ @stylesheet = :bundled
48
+ @field_types = FieldTypes.new
49
+ end
50
+
51
+ def theme=(tokens)
52
+ @theme = tokens.to_h
53
+ end
54
+
55
+ def dark_theme=(tokens)
56
+ @dark_theme = tokens.to_h
57
+ end
58
+
59
+ def stylesheet=(mode)
60
+ mode = mode.to_sym
61
+ raise ArgumentError, "stylesheet must be one of #{STYLESHEET_MODES.join(', ')}" unless STYLESHEET_MODES.include?(mode)
62
+
63
+ @stylesheet = mode
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Everything a resource class declares through the DSL. Each resource class
5
+ # owns one; subclasses start from a deep copy of their parent's.
6
+ #
7
+ # Declarations are stored as given and resolved lazily (types inferred from
8
+ # the schema on first use), because resource classes are loaded to draw
9
+ # routes, which also happens when the database doesn't exist yet.
10
+ class Definition
11
+ BUILT_IN_ACTIONS = %i[index show new create edit update destroy].freeze
12
+
13
+ Field = Struct.new(:name, :declared_type, :options, :definition, keyword_init: true) do
14
+ def model = definition.model
15
+ def type = (declared_type || Administratum.field_types.infer(model, name)).to_sym
16
+ def label = options[:label]
17
+ def hint = options[:hint]
18
+ end
19
+
20
+ Column = Struct.new(:name, :declared_type, :label, :declared_sortable, :block, :condition, :csv, :definition,
21
+ keyword_init: true) do
22
+ def model = definition.model
23
+
24
+ def type
25
+ return declared_type if declared_type
26
+ return :association if model.reflect_on_association(name)&.macro == :belongs_to
27
+
28
+ case model.columns_hash[name.to_s]&.type
29
+ when :boolean then :boolean
30
+ when :datetime then :datetime
31
+ when :date then :date
32
+ else :text
33
+ end
34
+ end
35
+
36
+ def sortable
37
+ declared_sortable.nil? ? block.nil? && model.columns_hash.key?(name.to_s) : declared_sortable
38
+ end
39
+ end
40
+
41
+ Filter = Struct.new(:name, :declared_type, :label, :declared_collection, :declared_predicate, :definition,
42
+ keyword_init: true) do
43
+ def model = definition.model
44
+
45
+ def type
46
+ return declared_type if declared_type
47
+ return :select if belongs_to_reflection
48
+
49
+ case model.columns_hash[name.to_s]&.type
50
+ when :boolean then :boolean
51
+ when :integer, :float, :decimal then :number
52
+ when :date, :datetime then :date_range
53
+ else :string
54
+ end
55
+ end
56
+
57
+ def predicate
58
+ declared_predicate || (type == :string ? :cont : :eq)
59
+ end
60
+
61
+ def collection
62
+ return declared_collection if declared_collection
63
+ return unless type == :select
64
+
65
+ reflection = belongs_to_reflection
66
+ -> { reflection.klass.all } if reflection
67
+ end
68
+
69
+ def keys
70
+ type == :date_range ? ["#{name}_gteq", "#{name}_lteq"] : ["#{name}_#{predicate}"]
71
+ end
72
+
73
+ private
74
+
75
+ def belongs_to_reflection
76
+ return unless name.to_s.end_with?("_id")
77
+
78
+ reflection = model.reflect_on_association(name.to_s.delete_suffix("_id").to_sym)
79
+ reflection if reflection&.macro == :belongs_to
80
+ end
81
+ end
82
+
83
+ CustomAction = Struct.new(:name, :http_method, :scope, keyword_init: true) do
84
+ def member? = scope == :member
85
+ end
86
+
87
+ Button = Struct.new(:name, :label, :pages, :action, :to, :http_method, :confirm, :condition, :style, :panel, :upload,
88
+ keyword_init: true)
89
+
90
+ Parent = Struct.new(:name, :class_name, :shallow, :association, keyword_init: true) do
91
+ def model = class_name.constantize
92
+ def param = :"#{name}_id"
93
+ end
94
+
95
+ Menu = Struct.new(:label, :priority, :hidden, keyword_init: true)
96
+
97
+ # A tab above the index table. Without a block it calls the model scope of the same name.
98
+ IndexScope = Struct.new(:name, :label, :block, :default, :count, keyword_init: true) do
99
+ def apply(relation) = block ? block.call(relation) : relation.public_send(name)
100
+ def display_label = label || I18n.t("administratum.scopes.#{name}", default: name.to_s.humanize)
101
+ end
102
+
103
+ attr_accessor :model, :title_attribute, :permitted, :parent, :form_template, :scope_block, :route_key, :label,
104
+ :plural_label, :per_page, :default_sort, :csv
105
+ attr_reader :fields, :columns, :custom_actions, :buttons, :show_attributes, :enabled_actions, :menu, :index_scopes
106
+
107
+ def initialize
108
+ @fields = []
109
+ @columns = []
110
+ @filters = nil
111
+ @custom_actions = {}
112
+ @buttons = []
113
+ @show_attributes = []
114
+ @index_scopes = []
115
+ @enabled_actions = BUILT_IN_ACTIONS.dup
116
+ @menu = Menu.new(priority: 100, hidden: false)
117
+ @csv = false
118
+ end
119
+
120
+ def initialize_copy(source)
121
+ super
122
+ @fields = rebind(source.fields)
123
+ @columns = rebind(source.columns)
124
+ @filters = source.declared_filters && rebind(source.declared_filters)
125
+ @custom_actions = source.custom_actions.dup
126
+ @buttons = source.buttons.map(&:dup)
127
+ @show_attributes = rebind(source.show_attributes)
128
+ @index_scopes = source.index_scopes.dup
129
+ @enabled_actions = source.enabled_actions.dup
130
+ @menu = source.menu.dup
131
+ end
132
+
133
+ def declared_filters
134
+ @filters
135
+ end
136
+
137
+ def title
138
+ title_attribute || %i[title name].find { |attr| model.column_names.include?(attr.to_s) || model.method_defined?(attr) }
139
+ end
140
+
141
+ # Filters default to a "contains" search on the title attribute.
142
+ def filters
143
+ return @filters if @filters
144
+
145
+ title && model.column_names.include?(title.to_s) ? [build_filter(title)] : []
146
+ end
147
+
148
+ def add_filter(name, **)
149
+ @filters ||= []
150
+ @filters.reject! { |filter| filter.name == name.to_sym }
151
+ @filters << build_filter(name, **)
152
+ end
153
+
154
+ def clear_filters!
155
+ @filters = []
156
+ end
157
+
158
+ def build_filter(name, as: nil, label: nil, collection: nil, predicate: nil)
159
+ Filter.new(name: name.to_sym, declared_type: as&.to_sym, label:, declared_collection: collection,
160
+ declared_predicate: predicate&.to_sym, definition: self)
161
+ end
162
+
163
+ def index_columns
164
+ return columns if columns.any?
165
+
166
+ [:id, title, :updated_at].compact.uniq.select { |name| model.column_names.include?(name.to_s) }.map { |name| build_column(name) }
167
+ end
168
+
169
+ def add_column(name, as: nil, label: nil, sortable: nil, csv: true, &)
170
+ columns.reject! { |column| column.name == name.to_sym }
171
+ columns << build_column(name, as:, label:, sortable:, csv:, &)
172
+ end
173
+
174
+ def build_column(name, as: nil, label: nil, sortable: nil, csv: true, &block)
175
+ Column.new(name: name.to_sym, declared_type: as&.to_sym, label:, declared_sortable: sortable, block:, csv:,
176
+ definition: self)
177
+ end
178
+
179
+ def add_field(name, as: nil, **options)
180
+ fields.reject! { |field| field.name == name.to_sym }
181
+ fields << Field.new(name: name.to_sym, declared_type: as&.to_sym, options:, definition: self)
182
+ end
183
+
184
+ def field(name)
185
+ fields.find { |field| field.name == name.to_sym }
186
+ end
187
+
188
+ # The argument list passed to `permit`, with hash entries last as strong params requires.
189
+ def permitted_params
190
+ return permitted if permitted
191
+
192
+ scalars = []
193
+ nested = {}
194
+ fields.each do |field|
195
+ case Administratum.field_types.fetch(field.type).param
196
+ when :array then nested[field.name] = []
197
+ when :hash then nested[field.name] = {}
198
+ else scalars << field.name
199
+ end
200
+ end
201
+ nested.empty? ? scalars : scalars + [nested]
202
+ end
203
+
204
+ # Attribute names Ransack may filter and sort on for this resource.
205
+ def ransackable_attributes
206
+ names = filters.map { |filter| filter.name.to_s }
207
+ names += index_columns.select(&:sortable).map { |column| column.name.to_s }
208
+ names << title.to_s if title
209
+ names += Array(default_sort).map { |sort| sort.to_s.split.first }
210
+ names.uniq & model.column_names
211
+ end
212
+
213
+ def add_action(name, http_method:, scope:)
214
+ custom_actions[name.to_sym] = CustomAction.new(name: name.to_sym, http_method: http_method.to_sym, scope:)
215
+ end
216
+
217
+ def member_actions
218
+ custom_actions.values.select(&:member?)
219
+ end
220
+
221
+ def collection_actions
222
+ custom_actions.values.reject(&:member?)
223
+ end
224
+
225
+ def member_action_names
226
+ %i[show edit update destroy] + member_actions.map(&:name)
227
+ end
228
+
229
+ def enable_actions(only: nil, except: nil)
230
+ list = only ? Array(only).map(&:to_sym) : BUILT_IN_ACTIONS.dup
231
+ list -= Array(except).map(&:to_sym)
232
+ # A form is useless without the action that receives it.
233
+ list |= [:create] if list.include?(:new)
234
+ list |= [:update] if list.include?(:edit)
235
+ @enabled_actions = list & BUILT_IN_ACTIONS
236
+ end
237
+
238
+ def action_enabled?(name)
239
+ enabled_actions.include?(name.to_sym)
240
+ end
241
+
242
+ def add_button(name, on:, label: nil, action: nil, to: nil, method: nil, confirm: nil, condition: nil, style: :secondary,
243
+ panel: nil, upload: false)
244
+ buttons << Button.new(name: name.to_sym, label:, pages: Array(on).map(&:to_sym), action: (action || name).to_sym, to:,
245
+ http_method: method&.to_sym, confirm:, condition:, style: style.to_sym, panel:, upload:)
246
+ end
247
+
248
+ def buttons_for(page)
249
+ buttons.select { |button| button.pages.include?(page.to_sym) }
250
+ end
251
+
252
+ def add_index_scope(name, label: nil, default: false, count: true, &block)
253
+ index_scopes.reject! { |index_scope| index_scope.name == name.to_sym }
254
+ index_scopes << IndexScope.new(name: name.to_sym, label:, block:, default:, count:)
255
+ end
256
+
257
+ # The tab named by `name`, else the default tab (the first one unless one says `default: true`).
258
+ def index_scope(name)
259
+ index_scopes.find { |index_scope| index_scope.name.to_s == name.to_s } ||
260
+ index_scopes.find(&:default) || index_scopes.first
261
+ end
262
+
263
+ def add_show_attribute(name, as: nil, label: nil, condition: nil, &)
264
+ show_attributes << build_column(name, as:, label:, sortable: false, &).tap { |column| column.condition = condition }
265
+ end
266
+
267
+ def resolved_show_attributes
268
+ return show_attributes if show_attributes.any?
269
+
270
+ names = [:id] + fields.reject { |field| %i[password hidden].include?(field.type) }.map(&:name)
271
+ names = index_columns.map(&:name) if fields.empty?
272
+ names += %i[created_at updated_at]
273
+ names.uniq.select { |name| model.column_names.include?(name.to_s) || model.method_defined?(name) }
274
+ .map { |name| build_column(name, sortable: false) }
275
+ end
276
+
277
+ private
278
+
279
+ def rebind(items)
280
+ items.map { |item| item.dup.tap { |copy| copy.definition = self } }
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Class-level DSL for resource classes. See README for the full reference.
5
+ module DSL
6
+ # Declarations shared by resources and pages.
7
+ module Common
8
+ # A button on one or more pages (:index, :show, :new, :edit, :row, :page or a
9
+ # custom action's name). It triggers the custom action of the same name
10
+ # unless `action:` or `to:` (a lambda returning a path) is given.
11
+ def button(name, on:, label: nil, action: nil, to: nil, method: nil, confirm: nil, if: nil, style: :secondary,
12
+ panel: nil, upload: false)
13
+ definition.add_button(name, on:, label:, action:, to:, method:, confirm:, condition: binding.local_variable_get(:if),
14
+ style:, panel:, upload:)
15
+ end
16
+
17
+ # `menu false` hides it from navigation.
18
+ def menu(options = {})
19
+ if options == false
20
+ definition.menu.hidden = true
21
+ else
22
+ definition.menu.label = options[:label] if options[:label]
23
+ definition.menu.priority = options[:priority] if options[:priority]
24
+ end
25
+ end
26
+
27
+ def route(key)
28
+ definition.route_key = key.to_s
29
+ end
30
+
31
+ def controller_id
32
+ name.underscore.tr("/", "_")
33
+ end
34
+ end
35
+
36
+ include Common
37
+
38
+ # Builds `form do ... end` field lists.
39
+ class FormBuilder
40
+ def initialize(definition)
41
+ @definition = definition
42
+ end
43
+
44
+ def field(name, as: nil, **options)
45
+ @definition.add_field(name, as:, **options)
46
+ end
47
+ end
48
+
49
+ # Builds `index do ... end` column lists.
50
+ class IndexBuilder
51
+ def initialize(definition)
52
+ @definition = definition
53
+ end
54
+
55
+ def column(name, as: nil, label: nil, sortable: nil, csv: true, &block)
56
+ @definition.add_column(name, as:, label:, sortable:, csv:, &block)
57
+ end
58
+ end
59
+
60
+ # Builds `show do ... end` attribute lists.
61
+ class ShowBuilder
62
+ def initialize(definition)
63
+ @definition = definition
64
+ end
65
+
66
+ # `if:` takes a lambda, run in the view, that receives the record when it takes an argument.
67
+ def attribute(name, as: nil, label: nil, if: nil, &)
68
+ @definition.add_show_attribute(name, as:, label:, condition: binding.local_variable_get(:if), &)
69
+ end
70
+ end
71
+
72
+ def definition
73
+ @definition ||= superclass.respond_to?(:definition) ? superclass.definition.dup : Definition.new
74
+ @definition.model ||= inferred_model unless abstract_resource?
75
+ @definition
76
+ end
77
+
78
+ # Marks a class as a base for other resources: it gets no routes or menu entry.
79
+ def abstract_resource!
80
+ @abstract_resource = true
81
+ end
82
+
83
+ def abstract_resource?
84
+ @abstract_resource == true || name == "Administratum::Resource"
85
+ end
86
+
87
+ def model(klass = nil)
88
+ definition.model = klass if klass
89
+ definition.model
90
+ end
91
+
92
+ def title(attribute)
93
+ definition.title_attribute = attribute.to_sym
94
+ end
95
+
96
+ def label(singular, plural: nil)
97
+ definition.label = singular
98
+ definition.plural_label = plural
99
+ end
100
+
101
+ # Replaces the filter list. `filters` with no arguments disables filtering.
102
+ def filters(*names)
103
+ definition.clear_filters!
104
+ names.each { |name| definition.add_filter(name) }
105
+ end
106
+
107
+ def filter(name, as: nil, label: nil, collection: nil, predicate: nil)
108
+ definition.add_filter(name, as:, label:, collection:, predicate:)
109
+ end
110
+
111
+ def index(per_page: nil, sort: nil, csv: nil, &block)
112
+ definition.per_page = per_page if per_page
113
+ definition.default_sort = sort if sort
114
+ definition.csv = csv unless csv.nil?
115
+ IndexBuilder.new(definition).instance_eval(&block) if block
116
+ end
117
+
118
+ def form(template: nil, &block)
119
+ definition.form_template = template if template
120
+ FormBuilder.new(definition).instance_eval(&block) if block
121
+ end
122
+
123
+ def show(&)
124
+ ShowBuilder.new(definition).instance_eval(&)
125
+ end
126
+
127
+ # Strong-params style list of writable attributes. Defaults to the form fields.
128
+ def permit(*attributes)
129
+ definition.permitted = attributes
130
+ end
131
+
132
+ # With a block only, narrows every query this resource makes (the block receives the relation).
133
+ # With a name, adds a tab above the index: `scope :published` calls Post.published, or pass a
134
+ # block. Options: `default: true`, `label:`, `count: false` to skip the count query.
135
+ def scope(name = nil, label: nil, default: false, count: true, &block)
136
+ return definition.add_index_scope(name, label:, default:, count:, &block) if name
137
+
138
+ definition.scope_block = block
139
+ end
140
+
141
+ # Nests this resource under another: `parent :fantasy, shallow: true`.
142
+ def parent(name, shallow: false, association: nil, class_name: nil)
143
+ definition.parent = Definition::Parent.new(
144
+ name: name.to_sym,
145
+ class_name: class_name || name.to_s.classify,
146
+ shallow:,
147
+ association: (association || definition.model.model_name.plural).to_sym
148
+ )
149
+ end
150
+
151
+ def actions(only: nil, except: nil)
152
+ definition.enable_actions(only:, except:)
153
+ end
154
+
155
+ def member_action(name, method: :get)
156
+ definition.add_action(name, http_method: method, scope: :member)
157
+ end
158
+
159
+ def collection_action(name, method: :get)
160
+ definition.add_action(name, http_method: method, scope: :collection)
161
+ end
162
+
163
+ def route_key
164
+ definition.route_key || definition.model.model_name.route_key
165
+ end
166
+
167
+ def singular_route_key
168
+ definition.route_key ? definition.route_key.singularize : definition.model.model_name.singular_route_key
169
+ end
170
+
171
+ def parent_resource
172
+ return unless definition.parent
173
+
174
+ Administratum.registry.for_model(definition.parent.model)
175
+ end
176
+
177
+ def singular_label
178
+ definition.label || definition.model.model_name.human
179
+ end
180
+
181
+ def plural_label
182
+ definition.plural_label || definition.model.model_name.human(count: 2, default: singular_label.pluralize)
183
+ end
184
+
185
+ def menu_label
186
+ definition.menu.label || plural_label
187
+ end
188
+
189
+ private
190
+
191
+ def inferred_model
192
+ name&.delete_suffix("Admin")&.safe_constantize
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Administratum
6
+
7
+ initializer "administratum.devise" do
8
+ require "devise/orm/active_record"
9
+ end
10
+
11
+ # Redraw routes when a resource file changes, since routes are derived
12
+ # from the resource definitions.
13
+ initializer "administratum.reloading" do |app|
14
+ checker = nil
15
+
16
+ config.after_initialize do
17
+ dirs = Administratum.registry.resource_dirs.index_with { ["rb"] }
18
+ checker = ActiveSupport::FileUpdateChecker.new([], dirs) { nil }
19
+ app.reloaders << checker
20
+ end
21
+
22
+ config.to_prepare do
23
+ Administratum.registry.reset!
24
+ if checker&.updated?
25
+ checker.execute
26
+ app.reload_routes!
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Registry of form field types. Each type is a partial plus the shape its
5
+ # value takes in strong params (:scalar, :array or :hash).
6
+ class FieldTypes
7
+ Type = Struct.new(:name, :partial, :param, keyword_init: true)
8
+
9
+ PARAM_SHAPES = %i[scalar array hash].freeze
10
+
11
+ BUILT_IN = {
12
+ string: :scalar,
13
+ textarea: :scalar,
14
+ number: :scalar,
15
+ boolean: :scalar,
16
+ select: :scalar,
17
+ multi_select: :array,
18
+ belongs_to: :scalar,
19
+ has_many_ids: :array,
20
+ date: :scalar,
21
+ datetime: :scalar,
22
+ email: :scalar,
23
+ password: :scalar,
24
+ hidden: :scalar,
25
+ json: :scalar,
26
+ island: :scalar
27
+ }.freeze
28
+
29
+ COLUMN_TYPES = {
30
+ text: :textarea,
31
+ integer: :number,
32
+ float: :number,
33
+ decimal: :number,
34
+ boolean: :boolean,
35
+ date: :date,
36
+ datetime: :datetime,
37
+ json: :json,
38
+ jsonb: :json
39
+ }.freeze
40
+
41
+ def initialize
42
+ @types = {}
43
+ BUILT_IN.each { |name, param| register(name, partial: "administratum/fields/#{name}", param:) }
44
+ end
45
+
46
+ # Registers a field type. The partial receives form:, field:, resource: and value:.
47
+ def register(name, partial:, param: :scalar)
48
+ raise ArgumentError, "param must be one of #{PARAM_SHAPES.join(', ')}" unless PARAM_SHAPES.include?(param)
49
+
50
+ @types[name.to_sym] = Type.new(name: name.to_sym, partial:, param:)
51
+ end
52
+
53
+ def fetch(name)
54
+ @types.fetch(name.to_sym) { raise Administratum::Error, "Unknown field type :#{name}. Register it with config.field_types.register." }
55
+ end
56
+
57
+ def key?(name)
58
+ @types.key?(name.to_sym)
59
+ end
60
+
61
+ # Picks a field type from the model's column when `as:` is not given.
62
+ def infer(model, attribute)
63
+ name = attribute.to_s
64
+ return :password if name.include?("password")
65
+ return :email if name == "email"
66
+
67
+ reflection = model.reflect_on_association(name.delete_suffix("_id").to_sym)
68
+ return :belongs_to if name.end_with?("_id") && reflection&.macro == :belongs_to
69
+ return :has_many_ids if name.end_with?("_ids")
70
+
71
+ column = model.columns_hash[name]
72
+ return :string unless column
73
+
74
+ COLUMN_TYPES.fetch(column.type, :string)
75
+ end
76
+ end
77
+ end