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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administratum
|
|
4
|
+
# Class-level DSL for custom pages (Administratum::Page subclasses in app/admin).
|
|
5
|
+
module PageDSL
|
|
6
|
+
include DSL::Common
|
|
7
|
+
|
|
8
|
+
def definition
|
|
9
|
+
@definition ||= superclass.respond_to?(:definition) ? superclass.definition.dup : Definition.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Marks a class as a base for other pages: it gets no route or menu entry.
|
|
13
|
+
def abstract_page!
|
|
14
|
+
@abstract_page = true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def abstract_page?
|
|
18
|
+
@abstract_page == true || name == "Administratum::Page"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def label(text)
|
|
22
|
+
definition.label = text
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# An extra route on the page, /<route_key>/<name>, handled by the method of that name.
|
|
26
|
+
def page_action(name, method: :get)
|
|
27
|
+
definition.add_action(name, http_method: method, scope: :collection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Buttons on a page default to its header.
|
|
31
|
+
def button(name, on: :page, **)
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ReportsPage => "reports"; Stats::WeeklyPage => "stats_weekly".
|
|
36
|
+
def route_key
|
|
37
|
+
definition.route_key || name.delete_suffix("Page").underscore.tr("/", "_")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def page_title
|
|
41
|
+
definition.label || route_key.humanize
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def menu_label
|
|
45
|
+
definition.menu.label || page_title
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administratum
|
|
4
|
+
# Builds paths for a resource class from the route names Routing draws.
|
|
5
|
+
# `context` is anything that responds to the engine's *_path helpers.
|
|
6
|
+
class Paths
|
|
7
|
+
def initialize(resource, context)
|
|
8
|
+
@resource = resource
|
|
9
|
+
@context = context
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def index(parent = nil, **query)
|
|
13
|
+
call(collection_name, nested? ? [parent] : [], query)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def new(parent = nil, **query)
|
|
17
|
+
call("new_#{nested? ? "#{parent_key}_" : ''}#{singular}", nested? ? [parent] : [], query)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show(record, **query)
|
|
21
|
+
call(member_name, member_args(record), query)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def edit(record, **query)
|
|
25
|
+
call("edit_#{member_name}", member_args(record), query)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Path for a custom action. Member actions take the record, collection
|
|
29
|
+
# actions take the parent (when nested) or nothing.
|
|
30
|
+
def action(name, record_or_parent = nil, **query)
|
|
31
|
+
action = @resource.definition.custom_actions.fetch(name.to_sym)
|
|
32
|
+
if action.member?
|
|
33
|
+
call("#{name}_#{member_name}", member_args(record_or_parent), query)
|
|
34
|
+
else
|
|
35
|
+
call("#{name}_#{collection_name}", nested? ? [record_or_parent] : [], query)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Where a form for `record` submits to.
|
|
40
|
+
def form(record, parent = nil)
|
|
41
|
+
record.persisted? ? show(record) : index(parent || parent_of(record))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parent_of(record)
|
|
45
|
+
return unless nested?
|
|
46
|
+
|
|
47
|
+
record.public_send(@resource.definition.parent.name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def call(name, args, query)
|
|
53
|
+
@context.public_send(:"#{name}_path", *args, **query)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def nested?
|
|
57
|
+
@resource.definition.parent.present?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def shallow?
|
|
61
|
+
nested? && @resource.definition.parent.shallow
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def singular
|
|
65
|
+
@resource.singular_route_key
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parent_key
|
|
69
|
+
@resource.parent_resource.singular_route_key
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def collection_name
|
|
73
|
+
base = @resource.route_key == singular ? "#{singular}_index" : @resource.route_key
|
|
74
|
+
nested? ? "#{parent_key}_#{base}" : base
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def member_name
|
|
78
|
+
nested? && !shallow? ? "#{parent_key}_#{singular}" : singular
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def member_args(record)
|
|
82
|
+
nested? && !shallow? ? [parent_of(record), record] : [record]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administratum
|
|
4
|
+
# Prepended to each managed model's singleton class. When Ransack is called
|
|
5
|
+
# with auth_object: :administratum, the attributes a resource declares as
|
|
6
|
+
# filters or sortable columns are allowed, so models need no allowlist of
|
|
7
|
+
# their own. Other callers see the model's own allowlist unchanged.
|
|
8
|
+
module Ransackable
|
|
9
|
+
def self.install(model)
|
|
10
|
+
return if model.singleton_class.include?(self)
|
|
11
|
+
|
|
12
|
+
model.singleton_class.prepend(self)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ransackable_attributes(auth_object = nil)
|
|
16
|
+
return super unless auth_object == :administratum
|
|
17
|
+
|
|
18
|
+
Administratum.registry.ransackable_attributes_for(self) | own_allowlist(:ransackable_attributes, auth_object)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ransackable_associations(auth_object = nil)
|
|
22
|
+
return super unless auth_object == :administratum
|
|
23
|
+
|
|
24
|
+
own_allowlist(:ransackable_associations, auth_object)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The model's own allowlist, if it defines one. Ransack's default either
|
|
30
|
+
# raises (Ransack 4.1+) or, in some 4.x versions, allows every column with a
|
|
31
|
+
# deprecation warning; neither should widen what the admin can search.
|
|
32
|
+
def own_allowlist(name, auth_object)
|
|
33
|
+
definition = method(name).super_method
|
|
34
|
+
return [] if definition.nil? || definition.owner.name.to_s.start_with?("Ransack::")
|
|
35
|
+
|
|
36
|
+
Array(definition.call(auth_object)).map(&:to_s)
|
|
37
|
+
rescue RuntimeError
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administratum
|
|
4
|
+
# Finds every resource class. Classes are not stored here: they are looked up
|
|
5
|
+
# through Class#descendants after eager loading the resource directories, so
|
|
6
|
+
# code reloading in development needs no bookkeeping.
|
|
7
|
+
class Registry
|
|
8
|
+
def resources
|
|
9
|
+
ensure_loaded!
|
|
10
|
+
Administratum::Resource.descendants
|
|
11
|
+
.select { |klass| live?(klass) && !klass.abstract_resource? && enabled?(klass) }
|
|
12
|
+
.sort_by(&:name)
|
|
13
|
+
.each { |klass| Ransackable.install(klass.definition.model) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find_by_controller_id(id)
|
|
17
|
+
resources.find { |klass| klass.controller_id == id }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pages
|
|
21
|
+
ensure_loaded!
|
|
22
|
+
Administratum::Page.descendants.select { |klass| live?(klass) && !klass.abstract_page? }.sort_by(&:name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find_page_by_controller_id(id)
|
|
26
|
+
pages.find { |klass| klass.controller_id == id }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def top_level
|
|
30
|
+
resources.reject { |klass| klass.definition.parent }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def children_of(resource)
|
|
34
|
+
resources.select { |klass| klass.parent_resource == resource }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The resource that manages `model` at the top level, falling back to any.
|
|
38
|
+
def for_model(model)
|
|
39
|
+
candidates = resources.select { |klass| klass.definition.model == model }
|
|
40
|
+
candidates.find { |klass| klass.definition.parent.nil? } || candidates.first
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def menu_items
|
|
44
|
+
resources.reject { |klass| klass.definition.menu.hidden || klass.definition.parent || !klass.definition.action_enabled?(:index) }
|
|
45
|
+
.sort_by { |klass| [klass.definition.menu.priority, klass.plural_label] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resources and pages for the top navigation, by priority then label.
|
|
49
|
+
def navigation
|
|
50
|
+
(menu_items + pages.reject { |page| page.definition.menu.hidden })
|
|
51
|
+
.sort_by { |klass| [klass.definition.menu.priority, klass.menu_label] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ransackable_attributes_for(model)
|
|
55
|
+
resources.select { |klass| model <= klass.definition.model }.flat_map { |klass| klass.definition.ransackable_attributes }.uniq
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resource_dirs
|
|
59
|
+
dirs = Administratum.config.resource_paths.map { |path| Rails.root.join(path).to_s }
|
|
60
|
+
dirs << Administratum::Engine.root.join("app/admin").to_s
|
|
61
|
+
dirs.select { |dir| Dir.exist?(dir) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reset!
|
|
65
|
+
@loaded = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def ensure_loaded!
|
|
71
|
+
return if @loaded
|
|
72
|
+
|
|
73
|
+
@loaded = true
|
|
74
|
+
resource_dirs.each { |dir| load_dir(dir) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Another admin (ActiveAdmin, say) may own app/admin and take it away from
|
|
78
|
+
# Zeitwerk. Skip such a directory rather than break route loading.
|
|
79
|
+
def load_dir(dir)
|
|
80
|
+
Rails.autoloaders.main.eager_load_dir(dir)
|
|
81
|
+
rescue Zeitwerk::Error => e
|
|
82
|
+
Rails.logger&.warn("[Administratum] Skipping #{dir}: #{e.message}. Point config.resource_paths at a directory " \
|
|
83
|
+
"Rails autoloads, e.g. app/admin_next.")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# After a reload, stale classes can linger in descendants until GC.
|
|
87
|
+
def live?(klass)
|
|
88
|
+
klass.name.present? && klass.name.safe_constantize == klass
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def enabled?(klass)
|
|
92
|
+
Administratum.config.manage_users || klass.name != "Administratum::UserAdmin"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Administratum
|
|
4
|
+
# Draws one `resources` block per resource class, nesting children under
|
|
5
|
+
# their parent. Each route points at a controller name that
|
|
6
|
+
# Administratum::Resources resolves back to the resource class.
|
|
7
|
+
module Routing
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def draw(router)
|
|
11
|
+
Administratum.registry.reset!
|
|
12
|
+
Administratum.registry.top_level.each { |resource| draw_resource(router, resource) }
|
|
13
|
+
Administratum.registry.pages.each { |page| draw_page(router, page) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# GET /<route_key> plus one route per page_action, named <route_key>_page
|
|
17
|
+
# and <action>_<route_key>_page.
|
|
18
|
+
def draw_page(router, page)
|
|
19
|
+
controller = "pages/#{page.controller_id}"
|
|
20
|
+
key = page.route_key
|
|
21
|
+
router.get key, to: "#{controller}#index", as: "#{key}_page"
|
|
22
|
+
page.definition.custom_actions.each_value do |action|
|
|
23
|
+
router.public_send(action.http_method, "#{key}/#{action.name}", to: "#{controller}##{action.name}", as: "#{action.name}_#{key}_page")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def draw_resource(router, resource, nested: false)
|
|
28
|
+
definition = resource.definition
|
|
29
|
+
options = { controller: "resources/#{resource.controller_id}", only: definition.enabled_actions }
|
|
30
|
+
options[:shallow] = true if nested && definition.parent.shallow
|
|
31
|
+
|
|
32
|
+
router.resources(resource.route_key.to_sym, **options) do
|
|
33
|
+
definition.member_actions.each { |action| router.public_send(action.http_method, action.name, on: :member) }
|
|
34
|
+
definition.collection_actions.each { |action| router.public_send(action.http_method, action.name, on: :collection) }
|
|
35
|
+
Administratum.registry.children_of(resource).each { |child| draw_resource(router, child, nested: true) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Rails resolves "administratum/resources/fantasy_admin" to the constant
|
|
41
|
+
# Administratum::Resources::FantasyAdminController. Instead of defining those
|
|
42
|
+
# constants (which would go stale on reload), look the resource up each time.
|
|
43
|
+
module Resources
|
|
44
|
+
def self.const_missing(name)
|
|
45
|
+
id = name.to_s.delete_suffix("Controller").underscore
|
|
46
|
+
Administratum.registry.find_by_controller_id(id) || super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The same lookup for "administratum/pages/<controller_id>".
|
|
51
|
+
module Pages
|
|
52
|
+
def self.const_missing(name)
|
|
53
|
+
id = name.to_s.delete_suffix("Controller").underscore
|
|
54
|
+
Administratum.registry.find_page_by_controller_id(id) || super
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Administratum
|
|
6
|
+
# Host Tailwind mode: generates a CSS file with absolute paths into the
|
|
7
|
+
# installed gem, so an app's Tailwind v4 build can include Administratum's
|
|
8
|
+
# tokens, components and class sources. Import it after "tailwindcss" and
|
|
9
|
+
# override any --color-adm-* token in your own @theme.
|
|
10
|
+
module Tailwind
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def root
|
|
14
|
+
File.expand_path("../..", __dir__)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def contents
|
|
18
|
+
<<~CSS
|
|
19
|
+
/* Generated by bin/rails administratum:tailwind:sync. Do not edit; do not commit. */
|
|
20
|
+
@import "#{root}/app/assets/tailwind/administratum/theme.css";
|
|
21
|
+
@import "#{root}/app/assets/tailwind/administratum/components.css";
|
|
22
|
+
@source "#{root}/app/views";
|
|
23
|
+
@source "#{root}/app/helpers";
|
|
24
|
+
@source "#{root}/app/assets/javascripts";
|
|
25
|
+
CSS
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(path)
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
30
|
+
File.write(path, contents)
|
|
31
|
+
path
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "devise"
|
|
5
|
+
require "ransack"
|
|
6
|
+
require "pagy"
|
|
7
|
+
|
|
8
|
+
require "administratum/version"
|
|
9
|
+
require "administratum/configuration"
|
|
10
|
+
require "administratum/field_types"
|
|
11
|
+
require "administratum/definition"
|
|
12
|
+
require "administratum/dsl"
|
|
13
|
+
require "administratum/page_dsl"
|
|
14
|
+
require "administratum/paths"
|
|
15
|
+
require "administratum/registry"
|
|
16
|
+
require "administratum/ransackable"
|
|
17
|
+
require "administratum/routing"
|
|
18
|
+
require "administratum/engine"
|
|
19
|
+
|
|
20
|
+
module Administratum
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
|
|
23
|
+
def self.config
|
|
24
|
+
@config ||= Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.configure
|
|
28
|
+
yield config
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.registry
|
|
32
|
+
@registry ||= Registry.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.field_types
|
|
36
|
+
config.field_types
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Used by tests to start from a clean configuration.
|
|
40
|
+
def self.reset_config!
|
|
41
|
+
@config = Configuration.new
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Administratum
|
|
6
|
+
module Generators
|
|
7
|
+
# rails g administratum:install
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
class_option :path, type: :string, default: "/administratum", desc: "Where to mount the admin"
|
|
12
|
+
|
|
13
|
+
def copy_initializer
|
|
14
|
+
template "administratum.rb", "config/initializers/administratum.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_admin_directory
|
|
18
|
+
empty_directory "app/admin"
|
|
19
|
+
create_file "app/admin/.keep", ""
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def copy_migrations
|
|
23
|
+
rake "administratum:install:migrations"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mount_engine
|
|
27
|
+
route %(mount Administratum::Engine => "#{options[:path]}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show_next_steps
|
|
31
|
+
say <<~TEXT
|
|
32
|
+
|
|
33
|
+
Administratum is installed. Next:
|
|
34
|
+
1. bin/rails db:migrate
|
|
35
|
+
2. bin/rails administratum:create_user EMAIL=you@example.com
|
|
36
|
+
3. bin/rails g administratum:resource YourModel
|
|
37
|
+
4. Visit #{options[:path]}
|
|
38
|
+
TEXT
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Administratum.configure do |config|
|
|
4
|
+
# Shown in the top bar and the browser title.
|
|
5
|
+
config.app_name = "<%= Rails.application.class.module_parent_name.titleize %> Admin"
|
|
6
|
+
|
|
7
|
+
# config.logo = "admin/logo.svg"
|
|
8
|
+
# config.mailer_sender = "admin@example.com"
|
|
9
|
+
# config.per_page = 25
|
|
10
|
+
|
|
11
|
+
# Controller the admin inherits from. Use it for app-wide before_actions.
|
|
12
|
+
# config.parent_controller = "ActionController::Base"
|
|
13
|
+
|
|
14
|
+
# Partial rendered on the dashboard instead of the resource overview.
|
|
15
|
+
# config.dashboard = "admin/dashboard"
|
|
16
|
+
|
|
17
|
+
# Load your own JS (islands, Turbo, Stimulus…) and CSS into the admin <head>.
|
|
18
|
+
# config.head = ->(view) { view.javascript_importmap_tags("admin") }
|
|
19
|
+
# config.head = ->(view) { view.vite_javascript_tag("admin") }
|
|
20
|
+
# config.head_partial = "admin/head"
|
|
21
|
+
|
|
22
|
+
# Colour tokens (--color-adm-*). Applied on top of the bundled stylesheet.
|
|
23
|
+
# config.theme = { primary: "#7c3aed" }
|
|
24
|
+
# config.dark_theme = { primary: "#a78bfa" }
|
|
25
|
+
|
|
26
|
+
# :bundled uses the stylesheet shipped with the gem; :host means your
|
|
27
|
+
# Tailwind v4 build includes Administratum (see bin/rails administratum:tailwind:sync).
|
|
28
|
+
# config.stylesheet = :bundled
|
|
29
|
+
|
|
30
|
+
# Custom field types: a partial that receives form:, field:, resource:, value:.
|
|
31
|
+
# config.field_types.register :tags, partial: "admin/fields/tags"
|
|
32
|
+
|
|
33
|
+
# Extra helper modules for admin views.
|
|
34
|
+
# config.helpers = [ApplicationHelper]
|
|
35
|
+
|
|
36
|
+
# Resource templates are looked up in app/views/<view_prefix>/<resource>.
|
|
37
|
+
# config.view_prefix = "admin"
|
|
38
|
+
|
|
39
|
+
# Set to false to hide admin user management.
|
|
40
|
+
# config.manage_users = true
|
|
41
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Administratum
|
|
6
|
+
module Generators
|
|
7
|
+
# rails g administratum:resource Post
|
|
8
|
+
class ResourceGenerator < Rails::Generators::NamedBase
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
SKIPPED_COLUMNS = %w[id created_at updated_at].freeze
|
|
12
|
+
|
|
13
|
+
def create_resource_file
|
|
14
|
+
template "resource.rb", File.join("app/admin", class_path, "#{file_name}_admin.rb")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def model_class
|
|
20
|
+
class_name.safe_constantize
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def field_names
|
|
24
|
+
return [] unless model_class&.table_exists?
|
|
25
|
+
|
|
26
|
+
model_class.column_names - SKIPPED_COLUMNS - %w[encrypted_password password_digest]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= class_name %>Admin < Administratum::Resource
|
|
4
|
+
# filters :name
|
|
5
|
+
|
|
6
|
+
# index do
|
|
7
|
+
# column :name
|
|
8
|
+
# column :created_at
|
|
9
|
+
# end
|
|
10
|
+
|
|
11
|
+
form do
|
|
12
|
+
<% if field_names.any? -%>
|
|
13
|
+
<% field_names.each do |name| -%>
|
|
14
|
+
field :<%= name %>
|
|
15
|
+
<% end -%>
|
|
16
|
+
<% else -%>
|
|
17
|
+
# field :name
|
|
18
|
+
<% end -%>
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :administratum do
|
|
4
|
+
desc "Create an admin user: EMAIL=you@example.com [PASSWORD=secret]"
|
|
5
|
+
task create_user: :environment do
|
|
6
|
+
email = ENV.fetch("EMAIL") { abort "Set EMAIL=you@example.com" }
|
|
7
|
+
password = ENV["PASSWORD"].presence || SecureRandom.base58(16)
|
|
8
|
+
user = Administratum::User.create!(email:, password:, password_confirmation: password)
|
|
9
|
+
puts "Created #{user.email}"
|
|
10
|
+
puts "Password: #{password}" if ENV["PASSWORD"].blank?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
namespace :tailwind do
|
|
14
|
+
desc "Write the import file that adds Administratum to your own Tailwind v4 build"
|
|
15
|
+
task :sync do
|
|
16
|
+
require "administratum/tailwind"
|
|
17
|
+
path = Administratum::Tailwind.write(ENV.fetch("OUTPUT", "app/assets/tailwind/administratum.gen.css"))
|
|
18
|
+
puts "Wrote #{path}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Keep the generated import file current wherever assets are built.
|
|
24
|
+
# Declaring the prerequisite (rather than looking the task up) works whichever
|
|
25
|
+
# order the rake files load in.
|
|
26
|
+
# The generated file is gitignored, so on a fresh checkout it only exists after
|
|
27
|
+
# a sync; also hook in when the initializer opts into host mode.
|
|
28
|
+
host_mode = File.exist?("config/initializers/administratum.rb") &&
|
|
29
|
+
File.read("config/initializers/administratum.rb").match?(/^\s*config\.stylesheet\s*=\s*:host/)
|
|
30
|
+
if host_mode || File.exist?("app/assets/tailwind/administratum.gen.css")
|
|
31
|
+
%w[assets:precompile vite:build].each { |name| task(name).enhance(["administratum:tailwind:sync"]) }
|
|
32
|
+
end
|