From 7fbbb7256682d08e48b3c8d24ad7c31c9f8cad98 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Thu, 13 Nov 2025 18:54:00 -0800 Subject: [PATCH 01/11] Radio component --- Gemfile.lock | 5 + README.md | 23 ++ lib/superform/rails/components/radio.rb | 51 +++ lib/superform/rails/field.rb | 17 +- spec/superform/rails/components/radio_spec.rb | 302 ++++++++++++++++++ 5 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 lib/superform/rails/components/radio.rb create mode 100644 spec/superform/rails/components/radio_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 92e3849..91ae8a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,7 @@ GEM drb (2.2.1) erubi (1.13.1) ffi (1.17.1-arm64-darwin) + ffi (1.17.1-x86_64-darwin) ffi (1.17.1-x86_64-linux-gnu) formatador (1.1.0) globalid (1.2.1) @@ -146,6 +147,8 @@ GEM nio4r (2.7.4) nokogiri (1.18.9-arm64-darwin) racc (~> 1.4) + nokogiri (1.18.9-x86_64-darwin) + racc (~> 1.4) nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) notiffany (0.1.3) @@ -233,6 +236,7 @@ GEM securerandom (0.3.1) shellany (0.0.1) sqlite3 (2.7.3-arm64-darwin) + sqlite3 (2.7.3-x86_64-darwin) sqlite3 (2.7.3-x86_64-linux-gnu) stringio (3.1.1) thor (1.3.2) @@ -252,6 +256,7 @@ PLATFORMS arm64-darwin-22 arm64-darwin-23 arm64-darwin-24 + x86_64-darwin-21 x86_64-linux DEPENDENCIES diff --git a/README.md b/README.md index 1e92542..f37c3c5 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,29 @@ class SignupForm < Components::Form end end + # Radio buttons can be rendered individually or as a collection + div do + Field(:plan).label { "Choose your plan" } + # Radio collection with options - renders multiple radio buttons + Field(:plan).radio( + options: [ + ["free", "Free Plan"], # Free Plan + ["pro", "Pro Plan"], # Pro Plan + ["enterprise", "Enterprise"] # Enterprise + ] + ) + end + + # Or render individual radio buttons with custom markup + div do + Field(:gender).label { "Gender" } + Field(:gender).radio do |r| + div { r.button("m") { "Male" } } + div { r.button("f") { "Female" } } + div { r.button("o") { "Other" } } + end + end + div do Field(:agreement).label { "Check this box if you agree to give us your first born child" } Field(:agreement).checkbox(checked: true) diff --git a/lib/superform/rails/components/radio.rb b/lib/superform/rails/components/radio.rb new file mode 100644 index 0000000..c0cd3a8 --- /dev/null +++ b/lib/superform/rails/components/radio.rb @@ -0,0 +1,51 @@ +module Superform + module Rails + module Components + class Radio < Field + def initialize( + *, + options: [], + **, + & + ) + super(*, **, &) + @options = options + end + + def view_template(&block) + if block_given? + yield self + else + buttons(*@options) + end + end + + def buttons(*collection) + map_options(collection).each do |value, label| + button(value) { label } + end + end + + def button(value, &block) + input( + **attributes, + type: :radio, + id: "#{dom.id}_#{value}", + value: value, + checked: field.value.to_s == value.to_s + ) + plain(yield) if block_given? + end + + protected + def map_options(collection) + OptionMapper.new(collection) + end + + def field_attributes + { name: dom.name } + end + end + end + end +end diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index f82325f..05961cb 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -137,8 +137,21 @@ def file(*, **, &) input(*, **, type: :file, &) end - def radio(value, *, **, &) - input(*, **, type: :radio, value: value, &) + def radio(*args, **attributes, &) + # If options keyword is provided, create a Radio component for collection + if attributes.key?(:options) + options = attributes.delete(:options) + Components::Radio.new(field, attributes:, options:, &) + # If first arg is an array or multiple args provided, treat as collection + elsif args.length > 1 || (args.length == 1 && args.first.is_a?(Array)) + Components::Radio.new(field, attributes:, options: args, &) + # Single non-array arg is a radio button value (legacy API) + elsif args.length == 1 + input(type: :radio, value: args.first, **attributes, &) + # No args at all - error + else + raise ArgumentError, "radio requires either a value or options" + end end # Rails compatibility aliases diff --git a/spec/superform/rails/components/radio_spec.rb b/spec/superform/rails/components/radio_spec.rb new file mode 100644 index 0000000..3eafdd0 --- /dev/null +++ b/spec/superform/rails/components/radio_spec.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/BlockLength +RSpec.describe Superform::Rails::Components::Radio, type: :view do + let(:object) { double('object', sushi: sushi_value) } + let(:sushi_value) { nil } + let(:field) do + Superform::Rails::Field.new(:sushi, parent: nil, object: object) + end + let(:options) do + [ + ['shirako', 'Shirako'], + ['ankimo', 'Ankimo'], + ['tsubugai', 'Tsubugai'] + ] + end + let(:component) do + described_class.new(field, attributes: attributes, options: options) + end + let(:attributes) { {} } + + describe 'basic radio collection' do + subject { render(component) } + + it 'renders multiple radio inputs' do + expect(subject.scan(/]*type="radio"/).count).to eq(3) + end + + it 'renders radio inputs with correct values' do + expect(subject).to include('value="shirako"') + expect(subject).to include('value="ankimo"') + expect(subject).to include('value="tsubugai"') + end + + it 'renders unique IDs for each radio button' do + expect(subject).to include('id="sushi_shirako"') + expect(subject).to include('id="sushi_ankimo"') + expect(subject).to include('id="sushi_tsubugai"') + end + + it 'uses the same name for all radio buttons' do + expect(subject.scan(/name="sushi"/).count).to eq(3) + end + + it 'does not check any radio by default' do + expect(subject).not_to include('checked') + end + + it 'renders complete HTML structure' do + expect(subject).to eq( + 'Shirako' \ + 'Ankimo' \ + 'Tsubugai' + ) + end + end + + describe 'with selected value' do + let(:sushi_value) { 'ankimo' } + + subject { render(component) } + + it 'checks the matching radio button' do + expect(subject).to match( + /]*id="sushi_ankimo"[^>]*checked[^>]*>/ + ) + end + + it 'does not check other radio buttons' do + expect(subject).not_to match( + /]*id="sushi_shirako"[^>]*checked[^>]*>/ + ) + expect(subject).not_to match( + /]*id="sushi_tsubugai"[^>]*checked[^>]*>/ + ) + end + + it 'renders complete HTML structure with checked state' do + expect(subject).to eq( + 'Shirako' \ + 'Ankimo' \ + 'Tsubugai' + ) + end + end + + describe 'with numeric value' do + let(:object) { double('object', role_id: role_id_value) } + let(:role_id_value) { 2 } + let(:field) do + Superform::Rails::Field.new(:role_id, parent: nil, object: object) + end + let(:options) { [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] } + + subject { render(component) } + + it 'checks the matching radio button with numeric comparison' do + expect(subject).to match( + /]*id="role_id_2"[^>]*checked[^>]*>/ + ) + end + end + + describe 'using field helper method' do + let(:form_field) do + Superform::Rails::Field.new(:sushi, parent: nil, object: object) + end + + context 'with positional arguments' do + subject do + render( + form_field.radio( + ['shirako', 'Shirako'], + ['ankimo', 'Ankimo'], + ['tsubugai', 'Tsubugai'] + ) + ) + end + + it 'renders radio buttons from positional args' do + expect(subject).to include('value="shirako"') + expect(subject).to include('value="ankimo"') + expect(subject).to include('value="tsubugai"') + end + end + + context 'with options keyword argument' do + subject do + render( + form_field.radio( + options: [ + ['shirako', 'Shirako'], + ['ankimo', 'Ankimo'], + ['tsubugai', 'Tsubugai'] + ] + ) + ) + end + + it 'renders radio buttons from options kwarg' do + expect(subject).to include('value="shirako"') + expect(subject).to include('value="ankimo"') + expect(subject).to include('value="tsubugai"') + end + end + + context 'with single value (legacy API)' do + subject do + render(form_field.radio('shirako', id: 'custom_id')) + end + + it 'renders a single radio input' do + expect(subject).to include('type="radio"') + expect(subject).to include('value="shirako"') + expect(subject).to include('id="custom_id"') + end + + it 'renders only one radio button' do + expect(subject.scan(/Chef's Choice') + end + + it 'generates ID from field name and value' do + expect(subject).to include('id="sushi_omakase"') + end + end + end + + describe 'inside a collection (with parent field)' do + let(:orders_field) do + orders_object = double('orders', orders: [{}, {}]) + Superform::Rails::Field.new( + :orders, + parent: nil, + object: orders_object, + value: [{}, {}] + ) + end + let(:order_collection) { orders_field.collection } + let(:order_field) { order_collection.field } + let(:sushi_field) do + sushi_object = double('order', sushi: nil) + Superform::Rails::Field.new(:sushi, parent: order_field, object: sushi_object) + end + let(:component) do + described_class.new( + sushi_field, + attributes: attributes, + options: options + ) + end + + subject { render(component) } + + it 'uses collection notation for field name' do + # When parent is a Field (from collection), it should use orders[][] + # First [] is the collection index, second [] is the field + expect(subject).to include('name="orders[][]"') + end + + it 'does not include the sushi key in the name' do + # The sushi key is excluded when parent is a Field + expect(subject).not_to include('name="orders[][][sushi]"') + end + + it 'generates unique IDs including collection index' do + expect(subject).to include('id="orders_1_sushi_shirako"') + expect(subject).to include('id="orders_1_sushi_ankimo"') + expect(subject).to include('id="orders_1_sushi_tsubugai"') + end + + it 'renders all radio buttons correctly' do + expect(subject.scan(/]*type="radio"/).count).to eq(3) + expect(subject).to include('value="shirako"') + expect(subject).to include('value="ankimo"') + expect(subject).to include('value="tsubugai"') + end + end + + describe 'with ActiveRecord::Relation' do + before do + User.create!(first_name: 'Alice', email: 'alice@example.com') + User.create!(first_name: 'Bob', email: 'bob@example.com') + end + + after do + User.delete_all + end + + let(:object) { double('object', author_id: author_id_value) } + let(:author_id_value) { nil } + let(:field) do + Superform::Rails::Field.new(:author_id, parent: nil, object: object) + end + let(:users_relation) { User.select(:id, :first_name) } + let(:component) do + described_class.new(field, attributes: attributes, options: [users_relation]) + end + + subject { render(component) } + + it 'renders radio buttons from ActiveRecord relation' do + # OptionMapper extracts id as value and joins other attributes as label + expect(subject).to match(/]*value="\d+"[^>]*>Alice/) + expect(subject).to match(/]*value="\d+"[^>]*>Bob/) + end + + it 'generates IDs from field name and record ID' do + alice = User.find_by(first_name: 'Alice') + expect(subject).to include("id=\"author_id_#{alice.id}\"") + end + end +end +# rubocop:enable Metrics/BlockLength From 14082d45da7a36a59155a0bf656c4a9bcd55c3cf Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Thu, 13 Nov 2025 19:55:08 -0800 Subject: [PATCH 02/11] Fix radio field naming and value handling --- README.md | 4 +- lib/superform/rails/components/radio.rb | 15 ++- .../radio_collection_integration_spec.rb | 122 ++++++++++++++++++ spec/superform/rails/components/radio_spec.rb | 2 +- 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 spec/superform/rails/components/radio_collection_integration_spec.rb diff --git a/README.md b/README.md index f37c3c5..e092015 100644 --- a/README.md +++ b/README.md @@ -359,10 +359,10 @@ class SignupForm < Components::Form end end - # Radio buttons can be rendered individually or as a collection + # Radio buttons can be rendered individually or as a group div do Field(:plan).label { "Choose your plan" } - # Radio collection with options - renders multiple radio buttons + # Radio group with options - renders multiple radio buttons Field(:plan).radio( options: [ ["free", "Free Plan"], # Free Plan diff --git a/lib/superform/rails/components/radio.rb b/lib/superform/rails/components/radio.rb index c0cd3a8..1c003e0 100644 --- a/lib/superform/rails/components/radio.rb +++ b/lib/superform/rails/components/radio.rb @@ -20,8 +20,8 @@ def view_template(&block) end end - def buttons(*collection) - map_options(collection).each do |value, label| + def buttons(*option_list) + map_options(option_list).each do |value, label| button(value) { label } end end @@ -32,14 +32,19 @@ def button(value, &block) type: :radio, id: "#{dom.id}_#{value}", value: value, - checked: field.value.to_s == value.to_s + checked: checked?(value) ) plain(yield) if block_given? end protected - def map_options(collection) - OptionMapper.new(collection) + def map_options(option_list) + OptionMapper.new(option_list) + end + + def checked?(value) + # Radio buttons are single-select, so field.value should never be an array + field.value.to_s == value.to_s end def field_attributes diff --git a/spec/superform/rails/components/radio_collection_integration_spec.rb b/spec/superform/rails/components/radio_collection_integration_spec.rb new file mode 100644 index 0000000..5b13a3c --- /dev/null +++ b/spec/superform/rails/components/radio_collection_integration_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +RSpec.describe "Radio in Collection Integration", type: :view do + # Set up test database for collection models + before(:all) do + ActiveRecord::Schema.define do + create_table :orders, force: true do |t| + t.string :sushi + end + end unless ActiveRecord::Base.connection.table_exists?(:orders) + end + + after(:all) do + ActiveRecord::Schema.define do + drop_table :orders if ActiveRecord::Base.connection.table_exists?(:orders) + end + end + + # Test model for collection (array of objects) + class Order < ActiveRecord::Base + end + + # Note: Field collections (arrays of primitives) don't make sense for radio buttons + # since radios are for single-select, not multiple array elements. + # For multi-select from primitives, use checkboxes instead. + + describe "collection (array of objects)" do + let(:initial_orders) do + [ + Order.new(sushi: "shirako"), + Order.new(sushi: "ankimo") + ] + end + let(:model) do + # Simulates a model with has_many association + # Using User with a mock orders association + orders_list = initial_orders # capture for closure + User.new(first_name: "Test", email: "test@example.com").tap do |user| + user.define_singleton_method(:orders) { @orders ||= orders_list } + user.define_singleton_method(:orders=) { |val| @orders = val } + end + end + let(:form) { Superform::Rails::Form.new(model, action: "/users") } + + it "renders radio buttons with collection notation" do + html = render(form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:sushi).radio( + options: [ + ["shirako", "Shirako"], + ["ankimo", "Ankimo"], + ["tsubugai", "Tsubugai"] + ] + ) + end + end + + # Collection uses model_name[collection_name][index][field_name] notation + # Rails will parse { "0" => {}, "1" => {} } into an array + expect(html).to include('name="user[orders][0][sushi]"') + expect(html).to include('name="user[orders][1][sushi]"') + expect(html.scan(/type="radio"/).count).to eq(6) # 3 radios × 2 orders + end + + it "pre-selects radio buttons based on collection values" do + html = render(form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:sushi).radio( + options: [ + ["shirako", "Shirako"], + ["ankimo", "Ankimo"], + ["tsubugai", "Tsubugai"] + ] + ) + end + end + + # Should have exactly 2 checked radios (one per order) + expect(html.scan(/checked/).count).to eq(2) + # "shirako" should be checked (first order) + expect(html).to match(/]*id="user_orders_0_sushi_shirako"[^>]*checked/) + # "ankimo" should be checked (second order) + expect(html).to match(/]*id="user_orders_1_sushi_ankimo"[^>]*checked/) + end + + it "works with submitted params from collection" do + # Simulate Rails params after form submission + # Collection radios submit as: { "user" => { "orders" => [{ "sushi" => "tsubugai" }, { "sushi" => "shirako" }] } } + submitted_model = User.new(first_name: "Test", email: "test@example.com").tap do |user| + user.define_singleton_method(:orders) do + [ + Order.new(sushi: "tsubugai"), + Order.new(sushi: "shirako") + ] + end + end + submitted_form = Superform::Rails::Form.new(submitted_model, action: "/users") + + html = render(submitted_form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:sushi).radio( + options: [ + ["shirako", "Shirako"], + ["ankimo", "Ankimo"], + ["tsubugai", "Tsubugai"] + ] + ) + end + end + + # Should have exactly 2 checked radios + expect(html.scan(/checked/).count).to eq(2) + # First order should now have "tsubugai" checked + expect(html).to match(/]*id="user_orders_0_sushi_tsubugai"[^>]*checked/) + # Second order should now have "shirako" checked + expect(html).to match(/]*id="user_orders_1_sushi_shirako"[^>]*checked/) + end + end +end diff --git a/spec/superform/rails/components/radio_spec.rb b/spec/superform/rails/components/radio_spec.rb index 3eafdd0..b86ff20 100644 --- a/spec/superform/rails/components/radio_spec.rb +++ b/spec/superform/rails/components/radio_spec.rb @@ -19,7 +19,7 @@ end let(:attributes) { {} } - describe 'basic radio collection' do + describe 'radio group with options' do subject { render(component) } it 'renders multiple radio inputs' do From ccbe08aeba9e2dca2f94da66892834c35cfca226 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Tue, 18 Nov 2025 15:57:26 -0800 Subject: [PATCH 03/11] Fix output, add AR collection support --- lib/superform/rails/components/radio.rb | 18 +++-- lib/superform/rails/field.rb | 20 +++-- .../radio_collection_integration_spec.rb | 24 +++--- spec/superform/rails/components/radio_spec.rb | 73 ++++++++++++------- .../rails/field_convenience_methods_spec.rb | 12 ++- 5 files changed, 81 insertions(+), 66 deletions(-) diff --git a/lib/superform/rails/components/radio.rb b/lib/superform/rails/components/radio.rb index 1c003e0..f652a25 100644 --- a/lib/superform/rails/components/radio.rb +++ b/lib/superform/rails/components/radio.rb @@ -27,14 +27,16 @@ def buttons(*option_list) end def button(value, &block) - input( - **attributes, - type: :radio, - id: "#{dom.id}_#{value}", - value: value, - checked: checked?(value) - ) - plain(yield) if block_given? + label do + input( + **attributes, + type: :radio, + id: "#{dom.id}_#{value}", + value: value.to_s, + checked: checked?(value) + ) + plain(yield) if block_given? + end end protected diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index 05961cb..bf3f3fc 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -138,19 +138,17 @@ def file(*, **, &) end def radio(*args, **attributes, &) - # If options keyword is provided, create a Radio component for collection - if attributes.key?(:options) - options = attributes.delete(:options) - Components::Radio.new(field, attributes:, options:, &) - # If first arg is an array or multiple args provided, treat as collection - elsif args.length > 1 || (args.length == 1 && args.first.is_a?(Array)) + # If multiple args or first arg is an array, treat as collection + if args.length > 1 || (args.length == 1 && args.first.is_a?(Array)) Components::Radio.new(field, attributes:, options: args, &) - # Single non-array arg is a radio button value (legacy API) - elsif args.length == 1 - input(type: :radio, value: args.first, **attributes, &) - # No args at all - error + # If single arg is an ActiveRecord::Relation, treat as collection + elsif args.length == 1 && defined?(ActiveRecord::Relation) && + args.first.is_a?(ActiveRecord::Relation) + Components::Radio.new(field, attributes:, options: args, &) + # No args or single non-collection arg - error else - raise ArgumentError, "radio requires either a value or options" + raise ArgumentError, + "radio requires multiple options (e.g., radio(['a', 'A'], ['b', 'B']))" end end diff --git a/spec/superform/rails/components/radio_collection_integration_spec.rb b/spec/superform/rails/components/radio_collection_integration_spec.rb index 5b13a3c..f4b9a71 100644 --- a/spec/superform/rails/components/radio_collection_integration_spec.rb +++ b/spec/superform/rails/components/radio_collection_integration_spec.rb @@ -47,11 +47,9 @@ class Order < ActiveRecord::Base orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| f.render order_namespace.field(:sushi).radio( - options: [ - ["shirako", "Shirako"], - ["ankimo", "Ankimo"], - ["tsubugai", "Tsubugai"] - ] + ["shirako", "Shirako"], + ["ankimo", "Ankimo"], + ["tsubugai", "Tsubugai"] ) end end @@ -68,11 +66,9 @@ class Order < ActiveRecord::Base orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| f.render order_namespace.field(:sushi).radio( - options: [ - ["shirako", "Shirako"], - ["ankimo", "Ankimo"], - ["tsubugai", "Tsubugai"] - ] + ["shirako", "Shirako"], + ["ankimo", "Ankimo"], + ["tsubugai", "Tsubugai"] ) end end @@ -102,11 +98,9 @@ class Order < ActiveRecord::Base orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| f.render order_namespace.field(:sushi).radio( - options: [ - ["shirako", "Shirako"], - ["ankimo", "Ankimo"], - ["tsubugai", "Tsubugai"] - ] + ["shirako", "Shirako"], + ["ankimo", "Ankimo"], + ["tsubugai", "Tsubugai"] ) end end diff --git a/spec/superform/rails/components/radio_spec.rb b/spec/superform/rails/components/radio_spec.rb index b86ff20..eb00c6d 100644 --- a/spec/superform/rails/components/radio_spec.rb +++ b/spec/superform/rails/components/radio_spec.rb @@ -48,9 +48,9 @@ it 'renders complete HTML structure' do expect(subject).to eq( - 'Shirako' \ - 'Ankimo' \ - 'Tsubugai' + '' \ + '' \ + '' ) end end @@ -77,9 +77,9 @@ it 'renders complete HTML structure with checked state' do expect(subject).to eq( - 'Shirako' \ - 'Ankimo' \ - 'Tsubugai' + '' \ + '' \ + '' ) end end @@ -124,39 +124,35 @@ end end - context 'with options keyword argument' do + context 'with mixed format positional arguments' do + let(:object) { double('object', contact: nil) } + let(:form_field) do + Superform::Rails::Field.new(:contact, parent: nil, object: object) + end + subject do render( form_field.radio( - options: [ - ['shirako', 'Shirako'], - ['ankimo', 'Ankimo'], - ['tsubugai', 'Tsubugai'] - ] + [true, 'Yes'], + [false, 'No'], + 'Hell no' ) ) end - it 'renders radio buttons from options kwarg' do - expect(subject).to include('value="shirako"') - expect(subject).to include('value="ankimo"') - expect(subject).to include('value="tsubugai"') - end - end - - context 'with single value (legacy API)' do - subject do - render(form_field.radio('shirako', id: 'custom_id')) + it 'renders radio buttons with array pairs using first as value' do + expect(subject).to include('value="true"') + expect(subject).to include('value="false"') end - it 'renders a single radio input' do - expect(subject).to include('type="radio"') - expect(subject).to include('value="shirako"') - expect(subject).to include('id="custom_id"') + it 'renders radio buttons with array pairs using second as label' do + expect(subject).to include('>Yes') + expect(subject).to include('>No') end - it 'renders only one radio button' do - expect(subject.scan(/Hell no') end end end @@ -297,6 +293,27 @@ alice = User.find_by(first_name: 'Alice') expect(subject).to include("id=\"author_id_#{alice.id}\"") end + + context 'passed directly via field helper' do + let(:form_field) do + Superform::Rails::Field.new(:author_id, parent: nil, object: object) + end + + subject do + # Pass relation directly without wrapping in array + render(form_field.radio(users_relation)) + end + + it 'renders radio buttons from ActiveRecord relation' do + expect(subject).to match(/]*value="\d+"[^>]*>Alice/) + expect(subject).to match(/]*value="\d+"[^>]*>Bob/) + end + + it 'generates proper HTML structure with labels' do + expect(subject).to include('