From fff0e702d464f852bf61cdd143d58fb0e66bf698 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:18:37 +0100 Subject: [PATCH 01/10] fix(views): apply call option on field values without link_to --- lib/tiny_admin/views/components/field_value.rb | 5 +++-- .../tiny_admin/views/components/field_value_spec.rb | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/tiny_admin/views/components/field_value.rb b/lib/tiny_admin/views/components/field_value.rb index 62e69e0..de94527 100644 --- a/lib/tiny_admin/views/components/field_value.rb +++ b/lib/tiny_admin/views/components/field_value.rb @@ -14,16 +14,17 @@ def initialize(field, value, record:) def view_template translated_value = field.translate_value(value) + display_value = field.apply_call_option(record) || translated_value value_class = field.options[:options]&.include?("value_class") ? "value-#{value}" : nil if field.options[:link_to] a(href: TinyAdmin.route_for(field.options[:link_to], reference: translated_value)) { span(class: value_class) { - render_value(field.apply_call_option(record) || translated_value) + render_value(display_value) } } else span(class: value_class) { - render_value(translated_value) + render_value(display_value) } end end diff --git a/spec/lib/tiny_admin/views/components/field_value_spec.rb b/spec/lib/tiny_admin/views/components/field_value_spec.rb index d58ee0a..952c62e 100644 --- a/spec/lib/tiny_admin/views/components/field_value_spec.rb +++ b/spec/lib/tiny_admin/views/components/field_value_spec.rb @@ -64,6 +64,18 @@ end end + describe "with call option (no link_to)" do + let(:field) { TinyAdmin::Field.new(name: "author_id", type: :integer, title: "Author", options: { call: "author, name" }) } + let(:author) { double("author", name: "John") } # rubocop:disable RSpec/VerifiedDoubles + let(:record) { double("record", id: 1, author: author) } # rubocop:disable RSpec/VerifiedDoubles + + it "renders the call result instead of the raw value", :aggregate_failures do + html = described_class.new(field, 42, record: record).call + expect(html).to include("John") + expect(html).not_to include("42") + end + end + describe "with value_class option" do let(:field) { TinyAdmin::Field.new(name: "status", type: :string, title: "Status", options: { options: ["value_class"] }) } let(:record) { double("record", id: 1) } # rubocop:disable RSpec/VerifiedDoubles From f5efb45e6d1d4f8ba2dffd42e485ddd04861a912 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:21:58 +0100 Subject: [PATCH 02/10] chore(router): instantiate repository once per resource request --- lib/tiny_admin/router.rb | 11 +++++------ sig/tiny_admin/router.rbs | 6 ++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/tiny_admin/router.rb b/lib/tiny_admin/router.rb index a49be68..6cec559 100644 --- a/lib/tiny_admin/router.rb +++ b/lib/tiny_admin/router.rb @@ -76,13 +76,13 @@ def setup_page_route(req, slug, page_data) def setup_resource_routes(req, slug, options:) req.on slug do - setup_collection_routes(req, slug, options: options) - setup_member_routes(req, slug, options: options) + repository = options[:repository].new(options[:model]) + setup_collection_routes(req, slug, options: options, repository: repository) + setup_member_routes(req, slug, options: options, repository: repository) end end - def setup_collection_routes(req, slug, options:) - repository = options[:repository].new(options[:model]) + def setup_collection_routes(req, slug, options:, repository:) action_options = options[:index] || {} # Custom actions @@ -112,8 +112,7 @@ def setup_collection_routes(req, slug, options:) end end - def setup_member_routes(req, slug, options:) - repository = options[:repository].new(options[:model]) + def setup_member_routes(req, slug, options:, repository:) action_options = (options[:show] || {}).merge(record_not_found_page: TinyAdmin.settings.record_not_found) req.on String do |reference| diff --git a/sig/tiny_admin/router.rbs b/sig/tiny_admin/router.rbs index dc92055..4fbae06 100644 --- a/sig/tiny_admin/router.rbs +++ b/sig/tiny_admin/router.rbs @@ -10,11 +10,13 @@ module TinyAdmin def root_route: (untyped) -> void - def setup_collection_routes: (untyped, String, options: Hash[Symbol, untyped]) -> void + def setup_collection_routes: (untyped, String, options: Hash[Symbol, untyped], repository: untyped) -> void def setup_custom_actions: (untyped, Array[Hash[Symbol, untyped]]?, options: Hash[Symbol, untyped], repository: untyped, slug: String, ?reference: untyped?) -> Hash[String, untyped] - def setup_member_routes: (untyped, String, Hash[Symbol, untyped]) -> void + def parse_action_config: (untyped) -> [Class, Symbol] + + def setup_member_routes: (untyped, String, options: Hash[Symbol, untyped], repository: untyped) -> void def setup_page_route: (untyped, String, Hash[Symbol, untyped]) -> void From 9d414e5c8de6a85a201f8e6e99312e6c40987f41 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:26:39 +0100 Subject: [PATCH 03/10] chore(views): improve ErrorPage css_class conversion Examples: - PageNotFound => page_not_found - PageNotAllowed => page_not_allowed - RecordNotFound => record_not_found - ErrorPage => error_page - HTMLError => html_error - MyXMLParser => my_xml_parser --- lib/tiny_admin/views/pages/error_page.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/tiny_admin/views/pages/error_page.rb b/lib/tiny_admin/views/pages/error_page.rb index 7cea6d4..d3796bc 100644 --- a/lib/tiny_admin/views/pages/error_page.rb +++ b/lib/tiny_admin/views/pages/error_page.rb @@ -16,8 +16,8 @@ def view_template def css_class self.class.name.split("::").last - .gsub(/([A-Z])/, '_\1') - .sub(/^_/, "") + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') .downcase end end From bd402b3c69efa705c8d997e8505ce858018ac9eb Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:33:25 +0100 Subject: [PATCH 04/10] chore(views): small internal changes to pagination --- lib/tiny_admin/views/components/pagination.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/tiny_admin/views/components/pagination.rb b/lib/tiny_admin/views/components/pagination.rb index c6d71e0..b31fa05 100644 --- a/lib/tiny_admin/views/components/pagination.rb +++ b/lib/tiny_admin/views/components/pagination.rb @@ -18,8 +18,8 @@ def view_template if pages <= 10 pages_range(1..pages) elsif current <= 4 || current >= pages - 3 - pages_range(1..(current <= 4 ? current + 2 : 4), with_dots: true) - pages_range((current > pages - 4 ? current - 2 : pages - 2)..pages) + pages_range(1..(current <= 4 ? (current + 2) : 4), with_dots: true) + pages_range((current > pages - 4 ? (current - 2) : (pages - 2))..pages) else pages_range(1..1, with_dots: true) pages_range((current - 2)..(current + 2), with_dots: true) @@ -39,7 +39,7 @@ def pages_range(range, with_dots: false) range.each do |page| li(class: page == current ? "page-item active" : "page-item") { href = query_string.empty? ? "?p=#{page}" : "?#{query_string}&p=#{page}" - a(class: "page-link", href: href) { page } + a(class: "page-link", href: href) { page.to_s } } end dots if with_dots From 5ecf0b39630da6725dd4bbf0b81f27e0a6c288b8 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:38:47 +0100 Subject: [PATCH 05/10] chore(plugins): replace @@opts class variable in SimpleAuth --- lib/tiny_admin/plugins/simple_auth.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/tiny_admin/plugins/simple_auth.rb b/lib/tiny_admin/plugins/simple_auth.rb index 25969b3..b88c39f 100644 --- a/lib/tiny_admin/plugins/simple_auth.rb +++ b/lib/tiny_admin/plugins/simple_auth.rb @@ -7,13 +7,13 @@ module Plugins module SimpleAuth class << self def configure(app, opts = {}) - @@opts = opts || {} # rubocop:disable Style/ClassVars - @@opts[:password] ||= ENV.fetch("ADMIN_PASSWORD_HASH", nil) # NOTE: fallback value + opts ||= {} + password_hash = opts[:password] || ENV.fetch("ADMIN_PASSWORD_HASH", nil) Warden::Strategies.add(:secret) do - def authenticate! + define_method(:authenticate!) do secret = params["secret"] || "" - return fail(:invalid_credentials) if Digest::SHA512.hexdigest(secret) != @@opts[:password] + return fail(:invalid_credentials) if Digest::SHA512.hexdigest(secret) != password_hash success!(app: "TinyAdmin") end From d0613445eefacd879c3b28387af74a34aa7b273c Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:40:11 +0100 Subject: [PATCH 06/10] chore(settings): improve convert_value error handling --- lib/tiny_admin/settings.rb | 10 ++++++++-- spec/lib/tiny_admin/settings_spec.rb | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/tiny_admin/settings.rb b/lib/tiny_admin/settings.rb index 9bd8cca..ed3b9b0 100644 --- a/lib/tiny_admin/settings.rb +++ b/lib/tiny_admin/settings.rb @@ -109,12 +109,18 @@ def convert_value(key, value) value.each_key do |key2| path = [key, key2] if (DEFAULTS[path].is_a?(Class) || DEFAULTS[path].is_a?(Module)) && self[key][key2].is_a?(String) - self[key][key2] = Object.const_get(self[key][key2]) + self[key][key2] = resolve_class(self[key][key2], setting: "#{key}.#{key2}") end end elsif value.is_a?(String) && (DEFAULTS[[key]].is_a?(Class) || DEFAULTS[[key]].is_a?(Module)) - self[key] = Object.const_get(self[key]) + self[key] = resolve_class(self[key], setting: key.to_s) end end + + def resolve_class(class_name, setting:) + Object.const_get(class_name) + rescue NameError => e + raise NameError, "TinyAdmin: invalid class '#{class_name}' for setting '#{setting}' - #{e.message}" + end end end diff --git a/spec/lib/tiny_admin/settings_spec.rb b/spec/lib/tiny_admin/settings_spec.rb index c5387bc..1e4509a 100644 --- a/spec/lib/tiny_admin/settings_spec.rb +++ b/spec/lib/tiny_admin/settings_spec.rb @@ -59,6 +59,16 @@ settings[:root_path] = "/admin" expect(settings[:root_path]).to eq("/admin") end + + it "raises a descriptive error for invalid class names" do + expect { settings[:helper_class] = "NonExistent::Klass" } + .to raise_error(NameError, /TinyAdmin: invalid class 'NonExistent::Klass' for setting 'helper_class'/) + end + + it "raises a descriptive error for invalid nested class names" do + expect { settings[:authentication] = { plugin: "NonExistent::Auth" } } + .to raise_error(NameError, /TinyAdmin: invalid class 'NonExistent::Auth' for setting 'authentication.plugin'/) + end end describe "dynamic option methods" do From 3594385dff040f487eb8d68eac1b15c39f290ab8 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:42:29 +0100 Subject: [PATCH 07/10] chore(views): guard Attributes#update_attributes send --- lib/tiny_admin/views/attributes.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/tiny_admin/views/attributes.rb b/lib/tiny_admin/views/attributes.rb index 455935d..67984b6 100644 --- a/lib/tiny_admin/views/attributes.rb +++ b/lib/tiny_admin/views/attributes.rb @@ -5,7 +5,12 @@ module Views module Attributes def update_attributes(attributes) attributes.each do |key, value| - send("#{key}=", value) + setter = "#{key}=" + unless respond_to?(setter) + raise ArgumentError, "#{self.class.name} does not support attribute '#{key}'" + end + + send(setter, value) end end end From e6811ea2649207bc79d5ed0e4bf7fc090d1da752 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:44:21 +0100 Subject: [PATCH 08/10] chore(core): make it explicit the reference to the navbar array --- lib/tiny_admin/store.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tiny_admin/store.rb b/lib/tiny_admin/store.rb index 04f9182..1aee10c 100644 --- a/lib/tiny_admin/store.rb +++ b/lib/tiny_admin/store.rb @@ -30,7 +30,7 @@ def prepare_sections(sections, logout:) end list << item if item end - navbar << logout if logout + @navbar << logout if logout end private From d846cfe75da27cbb9111708ee9606953845a7af2 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:48:52 +0100 Subject: [PATCH 09/10] feat(router): support non-GET methods in custom actions --- lib/tiny_admin/router.rb | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/tiny_admin/router.rb b/lib/tiny_admin/router.rb index 6cec559..40bd30b 100644 --- a/lib/tiny_admin/router.rb +++ b/lib/tiny_admin/router.rb @@ -148,10 +148,10 @@ def setup_member_routes(req, slug, options:, repository:) def setup_custom_actions(req, custom_actions = nil, options:, repository:, slug:, reference: nil) (custom_actions || []).each_with_object({}) do |custom_action, result| - action_slug, action = custom_action.first - action_class = to_class(action) + action_slug, action_config = custom_action.first + action_class, http_method = parse_action_config(action_config) - req.get action_slug.to_s do + req.public_send(http_method, action_slug.to_s) do authorize!(:custom_action, action_slug.to_s) do context = Context.new( actions: {}, @@ -170,6 +170,16 @@ def setup_custom_actions(req, custom_actions = nil, options:, repository:, slug: end end + def parse_action_config(config) + if config.is_a?(Hash) + action_class = to_class(config[:action] || config["action"]) + http_method = (config[:method] || config["method"] || "get").to_sym + [action_class, http_method] + else + [to_class(config), :get] + end + end + def authorization TinyAdmin.settings.authorization_class end From c720254fb60293969be5eb697f562f71a3f431b6 Mon Sep 17 00:00:00 2001 From: Mattia Roccoberton Date: Sat, 28 Mar 2026 09:57:08 +0100 Subject: [PATCH 10/10] feat(views): pass context to widgets --- lib/tiny_admin/views/actions/index.rb | 3 ++- lib/tiny_admin/views/actions/show.rb | 3 ++- lib/tiny_admin/views/components/widgets.rb | 19 +++++++++++++++++-- lib/tiny_admin/views/pages/content.rb | 2 +- lib/tiny_admin/views/pages/root.rb | 2 +- sig/tiny_admin/views/components/widgets.rbs | 7 ++++++- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/tiny_admin/views/actions/index.rb b/lib/tiny_admin/views/actions/index.rb index 7fe8288..89bacce 100644 --- a/lib/tiny_admin/views/actions/index.rb +++ b/lib/tiny_admin/views/actions/index.rb @@ -48,7 +48,8 @@ def view_template end } - render TinyAdmin::Views::Components::Widgets.new(widgets) + context = { slug: slug, records: records, params: params } + render TinyAdmin::Views::Components::Widgets.new(widgets, context: context) } end end diff --git a/lib/tiny_admin/views/actions/show.rb b/lib/tiny_admin/views/actions/show.rb index 3eb95ca..ff18c16 100644 --- a/lib/tiny_admin/views/actions/show.rb +++ b/lib/tiny_admin/views/actions/show.rb @@ -35,7 +35,8 @@ def view_template } end - render TinyAdmin::Views::Components::Widgets.new(widgets) + context = { slug: slug, record: record, reference: reference, params: params } + render TinyAdmin::Views::Components::Widgets.new(widgets, context: context) } end end diff --git a/lib/tiny_admin/views/components/widgets.rb b/lib/tiny_admin/views/components/widgets.rb index ef13e07..a45c930 100644 --- a/lib/tiny_admin/views/components/widgets.rb +++ b/lib/tiny_admin/views/components/widgets.rb @@ -4,8 +4,9 @@ module TinyAdmin module Views module Components class Widgets < BasicComponent - def initialize(widgets) + def initialize(widgets, context: {}) @widgets = widgets + @context = context end def view_template @@ -20,7 +21,7 @@ def view_template div(class: "col") { div(class: "card") { div(class: "card-body") { - render widget.new + render build_widget(widget) } } } @@ -29,6 +30,20 @@ def view_template end } end + + private + + def build_widget(widget) + key_params = [:key, :keyreq] + if widget.instance_method(:initialize).arity != 0 || + widget.instance_method(:initialize).parameters.any? { |type, _| key_params.include?(type) } + widget.new(context: @context) + else + widget.new + end + rescue ArgumentError + widget.new + end end end end diff --git a/lib/tiny_admin/views/pages/content.rb b/lib/tiny_admin/views/pages/content.rb index b860722..0b8e20c 100644 --- a/lib/tiny_admin/views/pages/content.rb +++ b/lib/tiny_admin/views/pages/content.rb @@ -11,7 +11,7 @@ def view_template unsafe_raw(content) } - render TinyAdmin::Views::Components::Widgets.new(widgets) + render TinyAdmin::Views::Components::Widgets.new(widgets, context: { params: params }) } end end diff --git a/lib/tiny_admin/views/pages/root.rb b/lib/tiny_admin/views/pages/root.rb index 0235e9a..c069331 100644 --- a/lib/tiny_admin/views/pages/root.rb +++ b/lib/tiny_admin/views/pages/root.rb @@ -7,7 +7,7 @@ class Root < DefaultLayout def view_template super do div(class: "root") { - render TinyAdmin::Views::Components::Widgets.new(widgets) + render TinyAdmin::Views::Components::Widgets.new(widgets, context: { params: params }) } end end diff --git a/sig/tiny_admin/views/components/widgets.rbs b/sig/tiny_admin/views/components/widgets.rbs index 3ab18e0..7aa6606 100644 --- a/sig/tiny_admin/views/components/widgets.rbs +++ b/sig/tiny_admin/views/components/widgets.rbs @@ -3,10 +3,15 @@ module TinyAdmin module Components class Widgets @widgets: Array[untyped]? + @context: Hash[Symbol, untyped] - def initialize: (Array[untyped]?) -> void + def initialize: (Array[untyped]?, ?context: Hash[Symbol, untyped]) -> void def view_template: () ?{ (untyped) -> void } -> void + + private + + def build_widget: (Class) -> untyped end end end