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
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Base class for resources declared in app/admin. A resource is a
5
+ # controller: the DSL configures it, the built-in actions provide CRUD, and
6
+ # any public method declared with member_action/collection_action is a
7
+ # custom action. Templates are looked up in app/views/admin/<route_key>
8
+ # first, then in the engine's defaults.
9
+ class Resource < ApplicationController
10
+ extend Administratum::DSL
11
+ include Pagy::Backend
12
+
13
+ before_action :load_parent
14
+ before_action :load_resource, if: :member_request?
15
+
16
+ helper_method :definition, :resource_class, :paths, :paths_for, :parent_record, :collection_path, :new_resource_path,
17
+ :resource_path, :edit_resource_path, :action_path, :resource_form_path, :parent_path, :current_index_scope,
18
+ :index_scope_count
19
+
20
+ def index
21
+ collection = current_index_scope ? current_index_scope.apply(scoped_collection) : scoped_collection
22
+ @search = collection.ransack(params[:q], auth_object: :administratum)
23
+ @search.sorts = definition.default_sort if @search.sorts.empty? && definition.default_sort
24
+
25
+ respond_to do |format|
26
+ format.html { @pagy, @resources = pagy(@search.result, limit: definition.per_page || Administratum.config.per_page) }
27
+ format.csv { send_data index_csv(@search.result), filename: "#{resource_class.route_key}.csv" } if definition.csv
28
+ end
29
+ end
30
+
31
+ def show; end
32
+
33
+ def new
34
+ @resource = build_resource
35
+ end
36
+
37
+ def edit; end
38
+
39
+ def create
40
+ @resource = build_resource(resource_params)
41
+
42
+ if @resource.save
43
+ redirect_to after_save_path(@resource), notice: t("administratum.flash.created", resource: resource_class.singular_label)
44
+ else
45
+ render :new, status: :unprocessable_content
46
+ end
47
+ end
48
+
49
+ def update
50
+ if @resource.update(resource_params)
51
+ redirect_to after_save_path(@resource), notice: t("administratum.flash.updated", resource: resource_class.singular_label)
52
+ else
53
+ render :edit, status: :unprocessable_content
54
+ end
55
+ end
56
+
57
+ def destroy
58
+ @resource.destroy!
59
+ redirect_to after_destroy_path, notice: t("administratum.flash.destroyed", resource: resource_class.singular_label)
60
+ end
61
+
62
+ # Resources defined in the host app live outside the engine's namespace,
63
+ # so Rails hands them the host's url helpers. Point them back at the engine.
64
+ def self.inherited(subclass)
65
+ super
66
+ subclass.include(Administratum::Engine.routes.url_helpers)
67
+ subclass.include(Administratum::Engine.routes.mounted_helpers)
68
+ end
69
+
70
+ def self.controller_path
71
+ name.nil? || abstract_resource? ? super : "administratum/resources/#{controller_id}"
72
+ end
73
+
74
+ private
75
+
76
+ # Templates for this resource live in app/views/<view_prefix>/<route_key>
77
+ # (app/views/admin/posts by default), falling back to the engine's defaults.
78
+ def _prefixes
79
+ @_prefixes ||= ["#{Administratum.config.view_prefix}/#{resource_class.route_key}", "administratum/resources"]
80
+ end
81
+
82
+ def resource_class
83
+ self.class
84
+ end
85
+
86
+ def definition
87
+ resource_class.definition
88
+ end
89
+
90
+ def model
91
+ definition.model
92
+ end
93
+
94
+ def parent_record
95
+ @parent
96
+ end
97
+
98
+ # Every query for this resource starts here. Override to add includes.
99
+ def scoped_collection
100
+ base = @parent ? @parent.public_send(definition.parent.association) : model.all
101
+ definition.scope_block ? definition.scope_block.call(base) : base
102
+ end
103
+
104
+ # The active index tab, from ?scope=, or nil when the resource declares none.
105
+ def current_index_scope
106
+ return @current_index_scope if defined?(@current_index_scope)
107
+
108
+ @current_index_scope = definition.index_scope(params[:scope])
109
+ end
110
+
111
+ # Records in a tab, with the current filters applied.
112
+ def index_scope_count(index_scope)
113
+ index_scope.apply(scoped_collection).ransack(params[:q], auth_object: :administratum).result.count
114
+ end
115
+
116
+ def build_resource(attributes = {})
117
+ @parent ? @parent.public_send(definition.parent.association).build(attributes) : model.new(attributes)
118
+ end
119
+
120
+ def resource_params
121
+ params.fetch(model.model_name.param_key, {}).permit(*definition.permitted_params)
122
+ end
123
+
124
+ def member_request?
125
+ definition.member_action_names.include?(action_name.to_sym)
126
+ end
127
+
128
+ def load_parent
129
+ parent = definition.parent
130
+ return unless parent && params[parent.param]
131
+
132
+ @parent = parent.model.find(params[parent.param])
133
+ end
134
+
135
+ # Non-shallow nested records are found through the parent; shallow ones
136
+ # get their parent from the record.
137
+ def load_resource
138
+ if @parent
139
+ @resource = scoped_collection.find(params[:id])
140
+ else
141
+ relation = definition.scope_block ? definition.scope_block.call(model.all) : model.all
142
+ @resource = relation.find(params[:id])
143
+ @parent = @resource.public_send(definition.parent.name) if definition.parent
144
+ end
145
+ end
146
+
147
+ def after_save_path(record)
148
+ if definition.action_enabled?(:show) then resource_path(record)
149
+ elsif definition.action_enabled?(:edit) then edit_resource_path(record)
150
+ else collection_path
151
+ end
152
+ end
153
+
154
+ # Resources without an index page (e.g. nested ones managed from their
155
+ # parent's page) go back to the parent, or the dashboard.
156
+ def after_destroy_path
157
+ return collection_path if definition.action_enabled?(:index)
158
+
159
+ parent_path || administratum.root_path
160
+ end
161
+
162
+ # Block columns run in a view context, as on the index page, and HTML
163
+ # they return is exported as its text. Columns declared csv: false are left out.
164
+ def index_csv(records)
165
+ columns = definition.index_columns.select(&:csv)
166
+ view = view_context
167
+ CSV.generate do |csv|
168
+ csv << columns.map { |column| view.column_label(column) }
169
+ records.find_each { |record| csv << columns.map { |column| csv_value(view, record, column) } }
170
+ end
171
+ end
172
+
173
+ def csv_value(view, record, column)
174
+ value = column.block ? view.instance_exec(record, &column.block) : record.public_send(column.name)
175
+
176
+ if value.is_a?(ActiveSupport::SafeBuffer) then Nokogiri::HTML5.fragment(value.to_s).text.strip
177
+ elsif value.is_a?(ActiveRecord::Base) then view.record_title(value)
178
+ else value
179
+ end
180
+ end
181
+
182
+ # Paths for this resource. See Administratum::Paths.
183
+ def paths
184
+ @paths ||= Administratum::Paths.new(resource_class, self)
185
+ end
186
+
187
+ def paths_for(resource)
188
+ Administratum::Paths.new(resource, self)
189
+ end
190
+
191
+ def collection_path(**)
192
+ paths.index(@parent, **)
193
+ end
194
+
195
+ def new_resource_path(**)
196
+ paths.new(@parent, **)
197
+ end
198
+
199
+ def resource_path(record = @resource, **)
200
+ paths.show(record, **)
201
+ end
202
+
203
+ def edit_resource_path(record = @resource, **)
204
+ paths.edit(record, **)
205
+ end
206
+
207
+ # Path for a custom action; member actions default to the current record.
208
+ def action_path(name, record = nil, **)
209
+ action = definition.custom_actions.fetch(name.to_sym)
210
+ paths.action(name, record || (action.member? ? @resource : @parent), **)
211
+ end
212
+
213
+ def resource_form_path(record = @resource)
214
+ paths.form(record, @parent)
215
+ end
216
+
217
+ def parent_path
218
+ return unless @parent
219
+
220
+ parent_resource = resource_class.parent_resource
221
+ Administratum::Paths.new(parent_resource, self).show(@parent) if parent_resource.definition.action_enabled?(:show)
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ class SessionsController < ApplicationController
5
+ # remember_me lives here; Devise only mixes it into its own controllers.
6
+ include Devise::Controllers::Rememberable
7
+
8
+ layout "administratum/authentication"
9
+
10
+ skip_before_action :authenticate_admin!, only: %i[new create]
11
+
12
+ def new; end
13
+
14
+ def create
15
+ user = Administratum::User.find_for_authentication(email: params[:email].to_s)
16
+
17
+ if user&.valid_for_authentication? { user.valid_password?(params[:password].to_s) } && user.active_for_authentication?
18
+ sign_in(:administratum_user, user)
19
+ remember_me(user) if params[:remember_me] == "1"
20
+ redirect_to stored_location_for(:administratum_user) || administratum.root_path
21
+ else
22
+ flash.now[:alert] = t("administratum.sessions.invalid")
23
+ render :new, status: :unprocessable_content
24
+ end
25
+ end
26
+
27
+ # Signs out only the admin scope, so a host app session survives.
28
+ def destroy
29
+ sign_out(:administratum_user)
30
+ redirect_to administratum.sign_in_path, notice: t("administratum.sessions.signed_out")
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ module ApplicationHelper
5
+ include Pagy::Frontend
6
+ include Administratum::LayoutHelper
7
+ include Administratum::FormHelper
8
+ include Administratum::ResourceHelper
9
+ include Administratum::IslandHelper
10
+ end
11
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ module FormHelper
5
+ # form_with pointed at the right URL and method for the record, whether it
6
+ # is new or persisted, nested or not. Use it in custom form templates.
7
+ def administratum_form(record = @resource, **options, &)
8
+ options[:class] = class_names("adm-form", options[:class])
9
+ form_with(model: record, scope: record.model_name.param_key, url: resource_form_path(record),
10
+ method: record.persisted? ? :patch : :post, **options, &)
11
+ end
12
+
13
+ # Renders the DSL's form fields, or the subset named in `only`.
14
+ def render_fields(form, only: nil, except: nil)
15
+ fields = definition.fields
16
+ fields = fields.select { |field| Array(only).map(&:to_sym).include?(field.name) } if only
17
+ fields = fields.reject { |field| Array(except).map(&:to_sym).include?(field.name) } if except
18
+ safe_join(fields.map { |field| render_field(form, field) })
19
+ end
20
+
21
+ # Renders one field with its label, hint and errors. Pass a name declared
22
+ # in the DSL, or a name plus `as:` and options for an ad-hoc field.
23
+ def render_field(form, field, as: nil, **options)
24
+ field = resolve_field(form, field, as:, options:)
25
+ type = Administratum.field_types.fetch(field.type)
26
+ locals = { form:, field:, resource: form.object, value: form.object.try(field.name) }
27
+ input = render(partial: type.partial, locals:)
28
+ return input if field.type == :hidden || field.options[:wrapper] == false
29
+
30
+ render "administratum/fields/wrapper", locals.merge(input:)
31
+ end
32
+
33
+ # "tag_ids" reads as "Tags"; "category_id" is already "Category" in Rails.
34
+ def field_label(form, field)
35
+ return field.label if field.label
36
+
37
+ name = field.name.to_s
38
+ name = name.delete_suffix("_ids").pluralize if name.end_with?("_ids")
39
+ form.object.class.human_attribute_name(name)
40
+ end
41
+
42
+ # `required:` on the field wins; otherwise a presence validation marks it.
43
+ def field_required?(form, field)
44
+ field.options.fetch(:required) do
45
+ model = form.object.class
46
+ return false unless model.respond_to?(:validators_on)
47
+
48
+ attributes = [field.name, field.name.to_s.delete_suffix("_id").to_sym].uniq
49
+ attributes.any? { |attribute| model.validators_on(attribute).any?(ActiveModel::Validations::PresenceValidator) }
50
+ end
51
+ end
52
+
53
+ def field_errors(form, field)
54
+ attribute = field.name.to_s.delete_suffix("_id").delete_suffix("_ids")
55
+ (form.object.errors[field.name] + form.object.errors[attribute.to_sym]).uniq
56
+ end
57
+
58
+ # Options for select-style fields. `collection` may be an array, a
59
+ # relation, or a lambda (optionally receiving the record).
60
+ def field_collection(field, record)
61
+ collection = field.options[:collection]
62
+ collection = collection.arity.zero? ? instance_exec(&collection) : instance_exec(record, &collection) if collection.respond_to?(:call)
63
+ collection ||= association_collection(field, record)
64
+ collection = collection.to_a if collection.is_a?(ActiveRecord::Relation)
65
+ collection.map { |item| item.is_a?(ActiveRecord::Base) ? [record_title(item), item.id] : item }
66
+ end
67
+
68
+ # HTML options for a field's input: the partial's defaults, then the
69
+ # field's placeholder and hint wiring, then its input_html overrides.
70
+ def input_options(field, form: nil, **defaults)
71
+ defaults[:placeholder] = field.options[:placeholder] if field.options[:placeholder]
72
+ defaults[:aria] = { describedby: form.field_id(field.name, :hint) } if field.hint && form
73
+ defaults.merge(field.options.fetch(:input_html, {}))
74
+ end
75
+
76
+ # A text input that shows and accepts ISO 8601 dates (YYYY-MM-DD) in any
77
+ # browser locale, unlike the native date input. `value` defaults to the
78
+ # form object's attribute; options override the input's HTML attributes.
79
+ def adm_date_field(form, name, value: form.object.try(name), **options)
80
+ form.text_field name, { value: iso_value(value, "%Y-%m-%d"), placeholder: "YYYY-MM-DD",
81
+ pattern: '\d{4}-\d{2}-\d{2}', inputmode: "numeric", class: "adm-input" }.merge(options)
82
+ end
83
+
84
+ # The same for datetimes (YYYY-MM-DD HH:MM). Rails parses the value in the
85
+ # app time zone on assignment.
86
+ def adm_datetime_field(form, name, value: form.object.try(name), **options)
87
+ form.text_field name, { value: iso_value(value, "%Y-%m-%d %H:%M"), placeholder: "YYYY-MM-DD HH:MM",
88
+ pattern: '\d{4}-\d{2}-\d{2} \d{2}:\d{2}', class: "adm-input" }.merge(options)
89
+ end
90
+
91
+ private
92
+
93
+ def resolve_field(form, field, as:, options:)
94
+ return field if field.is_a?(Administratum::Definition::Field)
95
+
96
+ declared = definition.field(field) if respond_to?(:definition)
97
+ return declared.dup.tap { |copy| copy.options = copy.options.merge(options) } if declared && as.nil?
98
+
99
+ type = (as || Administratum.field_types.infer(form.object.class, field)).to_sym
100
+ Administratum::Definition::Field.new(name: field.to_sym, type:, options:)
101
+ end
102
+
103
+ def association_collection(field, record)
104
+ name = field.name.to_s.delete_suffix("_ids").delete_suffix("_id")
105
+ reflection = record.class.reflect_on_association(name.to_sym) || record.class.reflect_on_association(name.pluralize.to_sym)
106
+ reflection ? reflection.klass.all : []
107
+ end
108
+
109
+ # Dates and times are formatted; anything else (such as a submitted
110
+ # string that failed validation) is shown as typed.
111
+ def iso_value(value, format)
112
+ value.respond_to?(:strftime) ? value.strftime(format) : value
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ module IslandHelper
5
+ # Renders a mount point for a JS island registered with
6
+ # Administratum.islands.register(name, (el, props) => teardown).
7
+ # Props are serialized as JSON; csrfToken is always included.
8
+ def island(name, props: {}, element: :div, **attributes)
9
+ props = { csrfToken: form_authenticity_token }.merge(props)
10
+ attributes[:data] = (attributes[:data] || {}).merge(administratum_island: name.to_s.dasherize)
11
+ content_tag(element, **attributes) do
12
+ tag.script(ERB::Util.json_escape(props.to_json).html_safe, type: "application/json") # rubocop:disable Rails/OutputSafety
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ module LayoutHelper
5
+ def administratum_config
6
+ Administratum.config
7
+ end
8
+
9
+ def administratum_title
10
+ [content_for(:title).presence, administratum_config.app_name].compact.join(" · ")
11
+ end
12
+
13
+ def administratum_stylesheet_tag
14
+ stylesheet_link_tag("administratum") if administratum_config.stylesheet == :bundled
15
+ end
16
+
17
+ # Token overrides from config.theme / config.dark_theme as CSS variables.
18
+ def administratum_theme_style
19
+ light = theme_declarations(administratum_config.theme)
20
+ dark = theme_declarations(administratum_config.dark_theme)
21
+ return if light.empty? && dark.empty?
22
+
23
+ css = +""
24
+ css << ":root{#{light}}" if light.present?
25
+ css << ":root.dark{#{dark}}@media (prefers-color-scheme: dark){:root:not(.light){#{dark}}}" if dark.present?
26
+ tag.style(css.html_safe, nonce: content_security_policy_nonce) # rubocop:disable Rails/OutputSafety
27
+ end
28
+
29
+ # Puts .dark or .light on <html> before first paint: the stored choice, else
30
+ # the OS preference. Always setting a class gives host CSS a reliable
31
+ # `.dark` hook (Tailwind's class-based dark variant, editor styles…).
32
+ def administratum_color_scheme_script
33
+ javascript_tag(<<~JS, nonce: true)
34
+ (function(){var m=null;try{m=localStorage.getItem("administratum-theme")}catch(e){}if(m!=="dark"&&m!=="light"){m=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}document.documentElement.classList.add(m)})();
35
+ JS
36
+ end
37
+
38
+ def administratum_head
39
+ safe_join([
40
+ administratum_config.head&.call(self),
41
+ (render(administratum_config.head_partial) if administratum_config.head_partial)
42
+ ].compact)
43
+ end
44
+
45
+ def administratum_nav_items
46
+ Administratum.registry.navigation.map do |item|
47
+ path = item < Administratum::Page ? administratum.public_send(:"#{item.route_key}_page_path") : Administratum::Paths.new(item, self).index
48
+ { label: item.menu_label, path:, active: nav_active?(item) }
49
+ end
50
+ end
51
+
52
+ # Renders the page header: breadcrumbs, title and the block's buttons.
53
+ def page_header(title:, breadcrumbs: nil, &block)
54
+ content_for(:title, title)
55
+ render "administratum/shared/page_header", title:, breadcrumbs: breadcrumbs || default_breadcrumbs,
56
+ actions: block ? capture(&block) : nil
57
+ end
58
+
59
+ def card(title: nil, footer: nil, flush: false, &)
60
+ render "administratum/shared/card", title:, footer:, flush:, body: capture(&)
61
+ end
62
+
63
+ private
64
+
65
+ def nav_active?(resource)
66
+ return controller.instance_of?(resource) if controller.is_a?(Administratum::Page)
67
+ return false unless controller.is_a?(Administratum::Resource)
68
+
69
+ current = controller.class
70
+ current = current.parent_resource while current.definition.parent && current.parent_resource && current != resource
71
+ current == resource
72
+ end
73
+
74
+ def theme_declarations(tokens)
75
+ tokens.map do |key, value|
76
+ name = key.to_s.start_with?("--") ? key.to_s : "--color-adm-#{key.to_s.dasherize}"
77
+ "#{name.gsub(/[^a-z0-9-]/i, '')}:#{value.to_s.gsub(/[;{}<>]/, '')};"
78
+ end.join
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ module ResourceHelper
5
+ BUTTON_STYLES = {
6
+ primary: "adm-btn adm-btn-primary",
7
+ secondary: "adm-btn adm-btn-secondary",
8
+ danger: "adm-btn adm-btn-danger",
9
+ ghost: "adm-btn adm-btn-ghost"
10
+ }.freeze
11
+
12
+ # The display name of any record: its resource's title attribute, else to_s.
13
+ def record_title(record)
14
+ return "" if record.nil?
15
+
16
+ resource = Administratum.registry.for_model(record.class)
17
+ attribute = resource&.definition&.title
18
+ value = attribute ? record.public_send(attribute) : nil
19
+ value.presence&.to_s || "#{record.model_name.human} ##{record.id}"
20
+ end
21
+
22
+ def column_label(column)
23
+ column.label || definition.model.human_attribute_name(column.name)
24
+ end
25
+
26
+ def column_value(record, column)
27
+ return instance_exec(record, &column.block) if column.block
28
+
29
+ format_value(record.public_send(column.name), column.type)
30
+ end
31
+
32
+ def format_value(value, type = nil)
33
+ return tag.span("–", class: "adm-muted") if value.nil? || value == ""
34
+
35
+ case type
36
+ when :boolean then boolean_badge(value)
37
+ when :datetime then l(value, format: :short)
38
+ when :date then l(value)
39
+ when :association then link_to_record(value)
40
+ else
41
+ if value.is_a?(ActiveRecord::Base) then link_to_record(value)
42
+ elsif value == true || value == false then boolean_badge(value)
43
+ elsif value.is_a?(Hash) || value.is_a?(Array) then tag.code(truncate(value.to_json, length: 80), class: "adm-code")
44
+ else value.to_s
45
+ end
46
+ end
47
+ end
48
+
49
+ def boolean_badge(value)
50
+ if value
51
+ tag.span(t("administratum.yes"), class: "adm-badge adm-badge-success")
52
+ else
53
+ tag.span(t("administratum.no"), class: "adm-badge")
54
+ end
55
+ end
56
+
57
+ # Links to the record's show page when a resource manages its model.
58
+ def link_to_record(record)
59
+ resource = Administratum.registry.for_model(record.class)
60
+ return record_title(record) unless resource&.definition&.action_enabled?(:show) && resource.definition.parent.nil?
61
+
62
+ link_to record_title(record), Administratum::Paths.new(resource, self).show(record)
63
+ end
64
+
65
+ def default_breadcrumbs
66
+ crumbs = [[administratum_config.app_name, administratum.root_path]]
67
+ crumbs << [page_class.page_title, page_path] if controller.is_a?(Administratum::Page) && action_name != "index"
68
+ return crumbs unless controller.is_a?(Administratum::Resource)
69
+
70
+ if parent_record && (parent_resource = resource_class.parent_resource)
71
+ parent_paths = Administratum::Paths.new(parent_resource, self)
72
+ crumbs << [parent_resource.plural_label, (parent_paths.index if parent_resource.definition.action_enabled?(:index))]
73
+ crumbs << [record_title(parent_record), parent_path]
74
+ end
75
+ # Resources without an index (managed from their parent) get no crumb of their own.
76
+ crumbs << [resource_class.plural_label, collection_path] if definition.action_enabled?(:index)
77
+ crumbs << [record_title(@resource), (resource_path if definition.action_enabled?(:show))] if @resource&.persisted? && action_name != "show"
78
+ crumbs
79
+ end
80
+
81
+ # Buttons for a page: built-ins (new, edit, delete…) followed by the
82
+ # resource's custom buttons. `record` is required on :show, :edit and :row.
83
+ def buttons_for(page, record = @resource)
84
+ buttons = built_in_buttons(page.to_sym, record)
85
+ buttons += definition.buttons_for(page).filter_map { |button| render_button(button, record) }
86
+ safe_join(buttons, " ")
87
+ end
88
+
89
+ def render_button(button, record = @resource)
90
+ return unless button_visible?(button, record)
91
+
92
+ label = button.label || t("administratum.actions.#{button.name}", default: button.name.to_s.humanize)
93
+ classes = BUTTON_STYLES.fetch(button.style, BUTTON_STYLES[:secondary])
94
+
95
+ return panel_button(button, label, classes, record) if button.panel || button.upload
96
+
97
+ url, http_method = button_target(button, record)
98
+ administratum_button(label, url, method: http_method, confirm: button.confirm, class: classes)
99
+ end
100
+
101
+ # A link for GET, otherwise a one-button form (no rails-ujs needed).
102
+ def administratum_button(label, url, method: :get, confirm: nil, **options)
103
+ if method.to_sym == :get
104
+ link_to label, url, **options
105
+ else
106
+ form = { data: confirm ? { adm_confirm: confirm } : {} }
107
+ button_to label, url, method:, form:, form_class: "adm-inline-form", **options
108
+ end
109
+ end
110
+
111
+ # Tabs for the resource's index scopes. Switching tab keeps filters and sorting
112
+ # but goes back to the first page.
113
+ def index_scope_tabs
114
+ return if definition.index_scopes.empty?
115
+
116
+ query = request.query_parameters.except("page", "scope").symbolize_keys
117
+ tabs = definition.index_scopes.map do |index_scope|
118
+ active = index_scope == current_index_scope
119
+ label = safe_join([index_scope.display_label,
120
+ (tag.span(number_with_delimiter(index_scope_count(index_scope)), class: "adm-tab-count") if index_scope.count)])
121
+ link_to label, collection_path(**query, scope: index_scope.name),
122
+ class: class_names("adm-tab", "adm-tab-active" => active), "aria-current": (active ? "page" : nil)
123
+ end
124
+ tag.nav(safe_join(tabs), class: "adm-tabs", "aria-label": t("administratum.scope_tabs"))
125
+ end
126
+
127
+ # Runs an `if:` lambda in the view, passing the record when it takes an argument.
128
+ def condition_met?(condition, record)
129
+ return true unless condition
130
+
131
+ condition.arity.zero? ? instance_exec(&condition) : instance_exec(record, &condition)
132
+ end
133
+
134
+ def filters?
135
+ definition.filters.any? && @search
136
+ end
137
+
138
+ def filter_collection(filter)
139
+ collection = filter.collection
140
+ collection = instance_exec(&collection) if collection.respond_to?(:call)
141
+ collection = collection.to_a if collection.is_a?(ActiveRecord::Relation)
142
+ collection.map { |item| item.is_a?(ActiveRecord::Base) ? [record_title(item), item.id] : item }
143
+ end
144
+
145
+ private
146
+
147
+ def built_in_buttons(page, record)
148
+ buttons = []
149
+ case page
150
+ when :index
151
+ buttons << link_to(t("administratum.actions.new", resource: resource_class.singular_label), new_resource_path, class: BUTTON_STYLES[:primary]) if definition.action_enabled?(:new)
152
+ when :show
153
+ buttons << link_to(t("administratum.actions.edit"), edit_resource_path(record), class: BUTTON_STYLES[:primary]) if definition.action_enabled?(:edit)
154
+ buttons << delete_button(record) if definition.action_enabled?(:destroy)
155
+ when :edit
156
+ buttons << link_to(t("administratum.actions.view"), resource_path(record), class: BUTTON_STYLES[:secondary]) if definition.action_enabled?(:show)
157
+ buttons << delete_button(record) if definition.action_enabled?(:destroy)
158
+ when :row
159
+ buttons << link_to(t("administratum.actions.view"), resource_path(record), class: "adm-btn adm-btn-ghost adm-btn-sm") if definition.action_enabled?(:show)
160
+ buttons << link_to(t("administratum.actions.edit"), edit_resource_path(record), class: "adm-btn adm-btn-ghost adm-btn-sm") if definition.action_enabled?(:edit)
161
+ buttons << delete_button(record, small: true) if definition.action_enabled?(:destroy)
162
+ end
163
+ buttons
164
+ end
165
+
166
+ def delete_button(record, small: false)
167
+ administratum_button(t("administratum.actions.destroy"), resource_path(record), method: :delete,
168
+ confirm: t("administratum.confirm"),
169
+ class: class_names("adm-btn adm-btn-danger", "adm-btn-sm" => small))
170
+ end
171
+
172
+ def button_visible?(button, record)
173
+ condition_met?(button.condition, record)
174
+ end
175
+
176
+ def button_target(button, record)
177
+ if button.to
178
+ url = button.to.arity.zero? ? instance_exec(&button.to) : instance_exec(record, &button.to)
179
+ return [url, button.http_method || :get]
180
+ end
181
+
182
+ action = definition.custom_actions.fetch(button.action) do
183
+ raise Administratum::Error, "Button :#{button.name} points at :#{button.action}, which is not a declared custom action"
184
+ end
185
+ [action_path(action.name, action.member? ? record : nil), button.http_method || action.http_method]
186
+ end
187
+
188
+ # Opens an offcanvas panel: either a custom partial, or a file upload form
189
+ # posting to the button's action.
190
+ def panel_button(button, label, classes, record)
191
+ panel_id = "adm-panel-#{button.name}#{"-#{record.id}" if record&.persisted?}"
192
+ content_for(:panels) do
193
+ render "administratum/shared/panel", id: panel_id, title: label, button:, record:,
194
+ target: (button_target(button, record).first unless button.panel)
195
+ end
196
+ tag.button(label, type: "button", class: classes, data: { adm_offcanvas_open: panel_id })
197
+ end
198
+ end
199
+ end