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,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
|