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.
- checksums.yaml +7 -0
- data/DESIGN.md +68 -0
- data/MIT-LICENSE +20 -0
- data/README.md +431 -0
- data/Rakefile +6 -0
- data/app/admin/administratum/user_admin.rb +39 -0
- data/app/assets/builds/administratum.css +2 -0
- data/app/assets/javascripts/administratum.js +250 -0
- data/app/assets/tailwind/administratum/components.css +199 -0
- data/app/assets/tailwind/administratum/index.css +8 -0
- data/app/assets/tailwind/administratum/theme.css +62 -0
- data/app/controllers/administratum/application_controller.rb +31 -0
- data/app/controllers/administratum/dashboard_controller.rb +7 -0
- data/app/controllers/administratum/page.rb +51 -0
- data/app/controllers/administratum/password_resets_controller.rb +42 -0
- data/app/controllers/administratum/resource.rb +224 -0
- data/app/controllers/administratum/sessions_controller.rb +33 -0
- data/app/helpers/administratum/application_helper.rb +11 -0
- data/app/helpers/administratum/form_helper.rb +115 -0
- data/app/helpers/administratum/island_helper.rb +16 -0
- data/app/helpers/administratum/layout_helper.rb +81 -0
- data/app/helpers/administratum/resource_helper.rb +199 -0
- data/app/mailers/administratum/application_mailer.rb +8 -0
- data/app/mailers/administratum/user_mailer.rb +11 -0
- data/app/models/administratum/application_record.rb +7 -0
- data/app/models/administratum/user.rb +13 -0
- data/app/views/administratum/dashboard/index.html.erb +16 -0
- data/app/views/administratum/fields/_belongs_to.html.erb +1 -0
- data/app/views/administratum/fields/_boolean.html.erb +1 -0
- data/app/views/administratum/fields/_date.html.erb +1 -0
- data/app/views/administratum/fields/_datetime.html.erb +1 -0
- data/app/views/administratum/fields/_email.html.erb +1 -0
- data/app/views/administratum/fields/_has_many_ids.html.erb +1 -0
- data/app/views/administratum/fields/_hidden.html.erb +1 -0
- data/app/views/administratum/fields/_island.html.erb +4 -0
- data/app/views/administratum/fields/_json.html.erb +1 -0
- data/app/views/administratum/fields/_multi_select.html.erb +1 -0
- data/app/views/administratum/fields/_number.html.erb +1 -0
- data/app/views/administratum/fields/_password.html.erb +1 -0
- data/app/views/administratum/fields/_select.html.erb +1 -0
- data/app/views/administratum/fields/_string.html.erb +1 -0
- data/app/views/administratum/fields/_textarea.html.erb +1 -0
- data/app/views/administratum/fields/_wrapper.html.erb +11 -0
- data/app/views/administratum/pages/index.html.erb +7 -0
- data/app/views/administratum/password_resets/edit.html.erb +17 -0
- data/app/views/administratum/password_resets/new.html.erb +11 -0
- data/app/views/administratum/resources/_attributes.html.erb +11 -0
- data/app/views/administratum/resources/_form.html.erb +14 -0
- data/app/views/administratum/resources/_table.html.erb +42 -0
- data/app/views/administratum/resources/edit.html.erb +7 -0
- data/app/views/administratum/resources/index.html.erb +17 -0
- data/app/views/administratum/resources/new.html.erb +7 -0
- data/app/views/administratum/resources/show.html.erb +7 -0
- data/app/views/administratum/sessions/new.html.erb +18 -0
- data/app/views/administratum/shared/_card.html.erb +9 -0
- data/app/views/administratum/shared/_filters.html.erb +43 -0
- data/app/views/administratum/shared/_flash.html.erb +9 -0
- data/app/views/administratum/shared/_form_errors.html.erb +10 -0
- data/app/views/administratum/shared/_nav.html.erb +10 -0
- data/app/views/administratum/shared/_page_header.html.erb +17 -0
- data/app/views/administratum/shared/_panel.html.erb +24 -0
- data/app/views/administratum/shared/_topbar.html.erb +29 -0
- data/app/views/administratum/user_mailer/reset_password.html.erb +4 -0
- data/app/views/administratum/user_mailer/reset_password.text.erb +7 -0
- data/app/views/layouts/administratum/application.html.erb +25 -0
- data/app/views/layouts/administratum/authentication.html.erb +32 -0
- data/config/locales/en.yml +65 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20261016000000_create_administratum_users.rb +17 -0
- data/lib/administratum/configuration.rb +66 -0
- data/lib/administratum/definition.rb +283 -0
- data/lib/administratum/dsl.rb +195 -0
- data/lib/administratum/engine.rb +31 -0
- data/lib/administratum/field_types.rb +77 -0
- data/lib/administratum/page_dsl.rb +48 -0
- data/lib/administratum/paths.rb +85 -0
- data/lib/administratum/ransackable.rb +41 -0
- data/lib/administratum/registry.rb +95 -0
- data/lib/administratum/routing.rb +57 -0
- data/lib/administratum/tailwind.rb +34 -0
- data/lib/administratum/version.rb +5 -0
- data/lib/administratum.rb +43 -0
- data/lib/generators/administratum/install/install_generator.rb +42 -0
- data/lib/generators/administratum/install/templates/administratum.rb +41 -0
- data/lib/generators/administratum/resource/resource_generator.rb +30 -0
- data/lib/generators/administratum/resource/templates/resource.rb.tt +20 -0
- data/lib/tasks/administratum.rake +32 -0
- 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
|