From 571c1995c1acd9b3bc4a1f2aac7e70d3584ea189 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Thu, 13 Nov 2025 12:43:06 -0800 Subject: [PATCH 01/15] Add multiple and include_blank keywords to select --- Gemfile.lock | 5 + README.md | 91 +++++---- lib/superform/rails/components/select.rb | 35 +++- .../superform/rails/components/select_spec.rb | 179 ++++++++++++++++++ superform.gemspec | 2 +- 5 files changed, 276 insertions(+), 36 deletions(-) create mode 100644 spec/superform/rails/components/select_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..ccd46f9 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,10 @@ You probably want to use the same form for creating and editing resources. In Su # app/views/posts/form.rb class Views::Posts::Form < Components::Form def view_template - Field(:title).text - Field(:body).textarea(rows: 10) - Field(:publish_at).date - Field(:featured).checkbox + field(:title).text + field(:body).textarea(rows: 10) + field(:publish_at).date + field(:featured).checkbox submit end end @@ -118,16 +118,16 @@ Superform includes helpers for all HTML5 input types: ```ruby class UserForm < Components::Form def view_template - Field(:email).email # type="email" - Field(:password).password # type="password" - Field(:website).url # type="url" - Field(:phone).tel # type="tel" - Field(:age).number(min: 18) # type="number" - Field(:birthday).date # type="date" - Field(:appointment).datetime # type="datetime-local" - Field(:favorite_color).color # type="color" - Field(:bio).textarea(rows: 5) - Field(:terms).checkbox + field(:email).email # type="email" + field(:password).password # type="password" + field(:website).url # type="url" + field(:phone).tel # type="tel" + field(:age).number(min: 18) # type="number" + field(:birthday).date # type="date" + field(:appointment).datetime # type="datetime-local" + field(:favorite_color).color # type="color" + field(:bio).textarea(rows: 5) + field(:terms).checkbox submit end end @@ -142,14 +142,14 @@ class Views::Posts::Form < Components::Form def view_template div(class: "form-section") do h2 { "Post Details" } - Field(:title).text(class: "form-control") - Field(:body).textarea(class: "form-control", rows: 10) + field(:title).text(class: "form-control") + field(:body).textarea(class: "form-control", rows: 10) end div(class: "form-section") do h2 { "Publishing" } - Field(:publish_at).date(class: "form-control") - Field(:featured).checkbox(class: "form-check-input") + field(:publish_at).date(class: "form-control") + field(:featured).checkbox(class: "form-check-input") end div(class: "form-actions") do @@ -232,19 +232,19 @@ class AccountForm < Superform::Rails::Form # Account#owner returns a single object namespace :owner do |owner| # Renders input with the name `account[owner][name]` - owner.Field(:name).text + owner.field(:name).text # Renders input with the name `account[owner][email]` - owner.Field(:email).email + owner.field(:email).email end # Account#members returns a collection of objects collection(:members).each do |member| # Renders input with the name `account[members][0][name]`, # `account[members][1][name]`, ... - member.Field(:name).input + member.field(:name).input # Renders input with the name `account[members][0][email]`, # `account[members][1][email]`, ... - member.Field(:email).input(type: :email) + member.field(:email).input(type: :email) # Member#permissions returns an array of values like # ["read", "write", "delete"]. @@ -252,7 +252,7 @@ class AccountForm < Superform::Rails::Form # Renders input with the name `account[members][0][permissions][]`, # `account[members][1][permissions][]`, ... render permission.label do - plain permisson.value.humanize + plain permission.value.humanize render permission.checkbox end end @@ -320,10 +320,10 @@ In practice, many of the calls below you'd put inside of a method. This cuts dow class SignupForm < Components::Form def view_template # The most basic type of input, which will be autofocused. - Field(:name).input.focus + field(:name).input.focus # Input field with a lot more options on it. - Field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) + field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) # You can put fields in a block if that's your thing. field(:reason) do |f| @@ -335,8 +335,8 @@ class SignupForm < Components::Form # Let's get crazy with Selects. They can accept values as simple as 2 element arrays. div do - Field(:contact).label { "Would you like us to spam you to death?" } - Field(:contact).select( + field(:contact).label { "Would you like us to spam you to death?" } + field(:contact).select( [true, "Yes"], # [false, "No"], # "Hell no", # @@ -345,8 +345,8 @@ class SignupForm < Components::Form end div do - Field(:source).label { "How did you hear about us?" } - Field(:source).select do |s| + field(:source).label { "How did you hear about us?" } + field(:source).select do |s| # Renders a blank option. s.blank_option # Pretend WebSources is an ActiveRecord scope with a "Social" category that has "Facebook, X, etc" @@ -359,9 +359,34 @@ class SignupForm < Components::Form end end + # Select with include_blank option div do - Field(:agreement).label { "Check this box if you agree to give us your first born child" } - Field(:agreement).checkbox(checked: true) + field(:country).label { "Select your country" } + field(:country).select([[1, "USA"], [2, "Canada"], [3, "Mexico"]], include_blank: true) + end + + # Multiple select for choosing multiple options + div do + field(:role_ids).label { "Select roles" } + field(:role_ids).select( + [[1, "Admin"], [2, "Editor"], [3, "Viewer"]], + multiple: true + ) + end + + # Multiple select with include_blank + div do + field(:tag_ids).label { "Select tags" } + field(:tag_ids).select( + [[1, "Ruby"], [2, "Rails"], [3, "Phlex"]], + multiple: true, + include_blank: true + ) + end + + div do + field(:agreement).label { "Check this box if you agree to give us your first born child" } + field(:agreement).checkbox(checked: true) end render button { "Submit" } @@ -376,9 +401,9 @@ If you want to add file upload fields to your form you will need to initialize y class User::ImageForm < Components::Form def view_template # render label - Field(:image).label { "Choose file" } + field(:image).label { "Choose file" } # render file input with accept attribute for png and jpeg images - Field(:image).input(type: "file", accept: "image/png, image/jpeg") + field(:image).input(type: "file", accept: "image/png, image/jpeg") end end diff --git a/lib/superform/rails/components/select.rb b/lib/superform/rails/components/select.rb index 8979591..3db61a7 100644 --- a/lib/superform/rails/components/select.rb +++ b/lib/superform/rails/components/select.rb @@ -2,16 +2,35 @@ module Superform module Rails module Components class Select < Field - def initialize(*, collection: [], **, &) + def initialize( + *, + collection: [], + multiple: false, + include_blank: false, + **, + & + ) super(*, **, &) @collection = collection + @multiple = multiple + @include_blank = include_blank end def view_template(&options) + # Hidden input ensures a value is sent even when all options are + # deselected in a multiple select + if @multiple + hidden_name = field.parent.is_a?(Superform::Field) ? dom.name : "#{dom.name}[]" + input(type: "hidden", name: hidden_name, value: "") + end + if block_given? select(**attributes, &options) else - select(**attributes) { options(*@collection) } + select(**attributes) do + blank_option if @include_blank + options(*@collection) + end end end @@ -37,6 +56,18 @@ def false_option(&) def map_options(collection) OptionMapper.new(collection) end + + def field_attributes + attrs = super + if @multiple + # Only append [] if the field doesn't already have a Field parent + # (which would mean it's already in a collection and has [] notation) + name = field.parent.is_a?(Superform::Field) ? attrs[:name] : "#{attrs[:name]}[]" + attrs.merge(multiple: true, name: name) + else + attrs + end + end end end end diff --git a/spec/superform/rails/components/select_spec.rb b/spec/superform/rails/components/select_spec.rb new file mode 100644 index 0000000..897663f --- /dev/null +++ b/spec/superform/rails/components/select_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/BlockLength +RSpec.describe Superform::Rails::Components::Select, type: :view do + let(:object) { double('object', role_ids: role_ids_value) } + let(:role_ids_value) { nil } + let(:field) do + Superform::Rails::Field.new(:role_ids, parent: nil, object: object) + end + let(:collection) { [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] } + let(:component) do + described_class.new(field, attributes: attributes, collection: collection) + end + let(:attributes) { {} } + + describe 'basic select' do + subject { render(component) } + + it 'renders a select element' do + expect(subject).to include('Admin') + expect(subject).to include('>Editor') + expect(subject).to include('>Viewer') + end + + it 'includes the field name' do + expect(subject).to include('name="role_ids"') + end + + it 'does not include multiple attribute' do + expect(subject).not_to include('multiple') + end + end + + describe 'with multiple: true' do + let(:component) do + described_class.new( + field, + attributes: attributes, + collection: collection, + multiple: true + ) + end + + subject { render(component) } + + it 'includes multiple attribute' do + expect(subject).to include('multiple') + end + + it 'appends [] to field name' do + expect(subject).to include('name="role_ids[]"') + end + + it 'renders hidden input before select' do + expect(subject).to match( + /.*' \ + '' \ + '' \ + '' \ + '' + ) + end end describe 'with multiple: true' do @@ -64,6 +74,17 @@ it 'renders hidden input with empty value' do expect(subject).to include('type="hidden" name="role_ids[]" value=""') end + + it 'renders complete HTML structure with hidden input' do + expect(subject).to eq( + '' \ + '' + ) + end end describe 'with include_blank: true' do @@ -87,6 +108,17 @@ %r{]*selected[^>]*>.*>Admin<}m ) end + + it 'renders complete HTML structure with blank option' do + expect(subject).to eq( + '' + ) + end end describe 'with multiple: true inside a collection' do @@ -130,6 +162,17 @@ it 'renders hidden input with correct name' do expect(subject).to include('type="hidden" name="users[][]" value=""') end + + it 'renders complete HTML structure without extra brackets' do + expect(subject).to eq( + '' \ + '' + ) + end end describe 'with both multiple and include_blank' do From 873b9d45900c683e5023661b24c2deaea8b36c0b Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Thu, 13 Nov 2025 12:56:25 -0800 Subject: [PATCH 03/15] Handle collection keyword correctly --- lib/superform/rails/field.rb | 13 ++++- .../superform/rails/components/select_spec.rb | 52 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index f82325f..f01d5a9 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -46,7 +46,18 @@ def textarea(**attributes) end def select(*collection, **attributes, &) - Components::Select.new(field, attributes:, collection:, &) + # Extract select-specific options from attributes if passed as keyword arguments + collection = attributes.delete(:collection) if collection.empty? && attributes.key?(:collection) + multiple = attributes.delete(:multiple) || false + include_blank = attributes.delete(:include_blank) || false + Components::Select.new( + field, + attributes:, + collection:, + multiple:, + include_blank:, + & + ) end def errors diff --git a/spec/superform/rails/components/select_spec.rb b/spec/superform/rails/components/select_spec.rb index 109ad8d..eaf75ce 100644 --- a/spec/superform/rails/components/select_spec.rb +++ b/spec/superform/rails/components/select_spec.rb @@ -195,6 +195,58 @@ end end + describe 'using field helper method' do + let(:form_field) do + Superform::Rails::Field.new(:role_ids, parent: nil, object: object) + end + + context 'with positional collection arguments' do + subject do + render(form_field.select([1, 'Admin'], [2, 'Editor'], [3, 'Viewer'])) + end + + it 'renders select with options from positional args' do + expect(subject).to include('>Admin') + expect(subject).to include('>Editor') + expect(subject).to include('>Viewer') + end + end + + context 'with collection keyword argument' do + subject do + render( + form_field.select( + collection: [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] + ) + ) + end + + it 'renders select with options from collection kwarg' do + expect(subject).to include('>Admin') + expect(subject).to include('>Editor') + expect(subject).to include('>Viewer') + end + end + + context 'with multiple: true keyword argument' do + subject do + render( + form_field.select( + collection: [[1, 'Admin'], [2, 'Editor']], + multiple: true + ) + ) + end + + it 'renders multiple select with collection kwarg' do + expect(subject).to include('multiple') + expect(subject).to include('name="role_ids[]"') + expect(subject).to include('>Admin') + expect(subject).to include('>Editor') + end + end + end + describe '#blank_option' do let(:component) do described_class.new(field, attributes: attributes, collection: []) From ba83438bc514cdd4139b013a667b85967a032d42 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Thu, 13 Nov 2025 13:16:19 -0800 Subject: [PATCH 04/15] Add options keyword, harmonize with Select component kwargs --- README.md | 23 ++++++++++++++---- lib/superform/rails/components/select.rb | 10 ++++---- lib/superform/rails/field.rb | 9 +++---- .../superform/rails/components/select_spec.rb | 24 +++++++++---------- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ccd46f9..30bc6cc 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,10 @@ class SignupForm < Components::Form end end - # Let's get crazy with Selects. They can accept values as simple as 2 element arrays. + # Selects accept options as positional arguments. Each option can be: + # - A 2-element array: [value, label] renders + # - A single value: "text" renders + # - nil: renders an empty div do field(:contact).label { "Would you like us to spam you to death?" } field(:contact).select( @@ -359,13 +362,16 @@ class SignupForm < Components::Form end end - # Select with include_blank option + # Select with include_blank: true adds a blank option at the start div do field(:country).label { "Select your country" } field(:country).select([[1, "USA"], [2, "Canada"], [3, "Mexico"]], include_blank: true) end - # Multiple select for choosing multiple options + # Multiple select with multiple: true + # - Adds the HTML 'multiple' attribute + # - Appends [] to the field name (role_ids becomes role_ids[]) + # - Includes a hidden input to handle empty submissions div do field(:role_ids).label { "Select roles" } field(:role_ids).select( @@ -374,9 +380,9 @@ class SignupForm < Components::Form ) end - # Multiple select with include_blank + # Combine multiple: true with include_blank: true div do - field(:tag_ids).label { "Select tags" } + field(:tag_ids).label { "Select tags (optional)" } field(:tag_ids).select( [[1, "Ruby"], [2, "Rails"], [3, "Phlex"]], multiple: true, @@ -384,6 +390,13 @@ class SignupForm < Components::Form ) end + # Select options can also be ActiveRecord relations or enumerables + div do + field(:author_id).label { "Select author" } + # Assumes User has id and name attributes + field(:author_id).select(*User.select(:id, :name)) + 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/select.rb b/lib/superform/rails/components/select.rb index 3db61a7..0d2a58c 100644 --- a/lib/superform/rails/components/select.rb +++ b/lib/superform/rails/components/select.rb @@ -4,19 +4,19 @@ module Components class Select < Field def initialize( *, - collection: [], + options: [], multiple: false, include_blank: false, **, & ) super(*, **, &) - @collection = collection + @options = options @multiple = multiple @include_blank = include_blank end - def view_template(&options) + def view_template(&block) # Hidden input ensures a value is sent even when all options are # deselected in a multiple select if @multiple @@ -25,11 +25,11 @@ def view_template(&options) end if block_given? - select(**attributes, &options) + select(**attributes, &block) else select(**attributes) do blank_option if @include_blank - options(*@collection) + options(*@options) end end end diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index f01d5a9..437b50c 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -45,15 +45,16 @@ def textarea(**attributes) Components::Textarea.new(field, attributes:) end - def select(*collection, **attributes, &) - # Extract select-specific options from attributes if passed as keyword arguments - collection = attributes.delete(:collection) if collection.empty? && attributes.key?(:collection) + def select(*options, **attributes, &) + # Extract select-specific parameters from attributes if passed as keyword arguments + # Note: positional args are the preferred API - keyword form is for internal use + options = attributes.delete(:options) if options.empty? && attributes.key?(:options) multiple = attributes.delete(:multiple) || false include_blank = attributes.delete(:include_blank) || false Components::Select.new( field, attributes:, - collection:, + options:, multiple:, include_blank:, & diff --git a/spec/superform/rails/components/select_spec.rb b/spec/superform/rails/components/select_spec.rb index eaf75ce..805befb 100644 --- a/spec/superform/rails/components/select_spec.rb +++ b/spec/superform/rails/components/select_spec.rb @@ -7,9 +7,9 @@ let(:field) do Superform::Rails::Field.new(:role_ids, parent: nil, object: object) end - let(:collection) { [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] } + let(:options) { [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] } let(:component) do - described_class.new(field, attributes: attributes, collection: collection) + described_class.new(field, attributes: attributes, options: options) end let(:attributes) { {} } @@ -50,7 +50,7 @@ described_class.new( field, attributes: attributes, - collection: collection, + options: options, multiple: true ) end @@ -92,7 +92,7 @@ described_class.new( field, attributes: attributes, - collection: collection, + options: options, include_blank: true ) end @@ -141,7 +141,7 @@ described_class.new( role_ids_field, attributes: attributes, - collection: collection, + options: options, multiple: true ) end @@ -180,7 +180,7 @@ described_class.new( field, attributes: attributes, - collection: collection, + options: options, multiple: true, include_blank: true ) @@ -212,16 +212,16 @@ end end - context 'with collection keyword argument' do + context 'with options keyword argument' do subject do render( form_field.select( - collection: [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] + options: [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] ) ) end - it 'renders select with options from collection kwarg' do + it 'renders select with options from options kwarg' do expect(subject).to include('>Admin') expect(subject).to include('>Editor') expect(subject).to include('>Viewer') @@ -232,13 +232,13 @@ subject do render( form_field.select( - collection: [[1, 'Admin'], [2, 'Editor']], + options: [[1, 'Admin'], [2, 'Editor']], multiple: true ) ) end - it 'renders multiple select with collection kwarg' do + it 'renders multiple select with options kwarg' do expect(subject).to include('multiple') expect(subject).to include('name="role_ids[]"') expect(subject).to include('>Admin') @@ -249,7 +249,7 @@ describe '#blank_option' do let(:component) do - described_class.new(field, attributes: attributes, collection: []) + described_class.new(field, attributes: attributes, options: []) end context 'when field value is nil' do From a4490b96323ec213546095a630dff5b93fc2c261 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Thu, 13 Nov 2025 13:25:00 -0800 Subject: [PATCH 05/15] Change and test ActiveRecord options doc --- README.md | 9 ++++--- .../superform/rails/components/select_spec.rb | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 30bc6cc..1d038e8 100644 --- a/README.md +++ b/README.md @@ -390,11 +390,14 @@ class SignupForm < Components::Form ) end - # Select options can also be ActiveRecord relations or enumerables + # Select options can also be ActiveRecord relations + # The relation is passed as a single argument (not splatted) + # OptionMapper extracts the primary key and joins other attributes for the label div do field(:author_id).label { "Select author" } - # Assumes User has id and name attributes - field(:author_id).select(*User.select(:id, :name)) + # For User.select(:id, :name), renders + # where id=1 is the primary key and "Alice" is the name attribute + field(:author_id).select(User.select(:id, :name)) end div do diff --git a/spec/superform/rails/components/select_spec.rb b/spec/superform/rails/components/select_spec.rb index 805befb..fec8e14 100644 --- a/spec/superform/rails/components/select_spec.rb +++ b/spec/superform/rails/components/select_spec.rb @@ -270,5 +270,29 @@ end 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(: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 options from ActiveRecord relation' do + # OptionMapper extracts id as value and joins other attributes as label + expect(subject).to match(/') + end + end + + context 'using deprecated collection keyword in field helper' do + let(:form_field) do + Superform::Rails::Field.new(:role_ids, parent: nil, object: object) + end + + it 'shows deprecation warning' do + expect(form_field).to receive(:warn).with( + "[DEPRECATION] Superform::Rails::Field#select: " \ + "`collection:` parameter is deprecated. " \ + "Use `options:` instead." + ) + form_field.select(collection: [[1, 'Admin'], [2, 'Editor']]) + end + + it 'still renders select correctly' do + # Suppress deprecation warning for this test + allow(form_field).to receive(:warn) + result = render( + form_field.select(collection: [[1, 'Admin'], [2, 'Editor']]) + ) + expect(result).to include('>Admin') + expect(result).to include('>Editor') + end + end + + context 'when both options and collection are provided' do + let(:component) do + described_class.new( + field, + attributes: attributes, + options: [[1, 'Admin']], + collection: [[2, 'Editor']] + ) + end + + it 'does not show deprecation warning' do + expect_any_instance_of(described_class).not_to receive(:warn) + component + end + + it 'uses options parameter (takes precedence)' do + result = render(component) + expect(result).to include('>Admin') + expect(result).not_to include('>Editor') + end + end + end end # rubocop:enable Metrics/BlockLength From d79ff0b194d3fa95dc586e52453c823db3e695a3 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Thu, 13 Nov 2025 19:59:43 -0800 Subject: [PATCH 07/15] Create select_collection_integration_spec.rb --- .../select_collection_integration_spec.rb | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 spec/superform/rails/components/select_collection_integration_spec.rb diff --git a/spec/superform/rails/components/select_collection_integration_spec.rb b/spec/superform/rails/components/select_collection_integration_spec.rb new file mode 100644 index 0000000..a3cff4a --- /dev/null +++ b/spec/superform/rails/components/select_collection_integration_spec.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +RSpec.describe "Select 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.integer :item_id + t.string :tag_ids + 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 + # Serialize tag_ids as JSON array for multiple select testing + serialize :tag_ids, coder: JSON + end + + describe "single select in collection" do + let(:initial_orders) do + [ + Order.new(item_id: 1), + Order.new(item_id: 2) + ] + end + let(:model) do + User.new(first_name: "Test", email: "test@example.com").tap do |user| + orders_list = initial_orders + 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") } + let(:item_options) { [[1, "Coffee"], [2, "Tea"], [3, "Juice"]] } + + it "renders select 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(:item_id).select(options: item_options) + end + end + + # Collection uses model_name[collection_name][index][field_name] notation + expect(html).to include('name="user[orders][0][item_id]"') + expect(html).to include('name="user[orders][1][item_id]"') + end + + it "pre-selects options 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(:item_id).select(options: item_options) + end + end + + # First order should have item_id=1 (Coffee) selected + first_select = html.match(/]*id="user_orders_0_item_id"[^>]*>.*?<\/select>/m)[0] + expect(first_select).to include('') + + # Second order should have item_id=2 (Tea) selected + second_select = html.match(/]*id="user_orders_1_item_id"[^>]*>.*?<\/select>/m)[0] + expect(second_select).to include('') + end + + it "works with submitted params from collection" do + # Simulate Rails params after form submission + submitted_model = User.new(first_name: "Test", email: "test@example.com").tap do |user| + user.define_singleton_method(:orders) do + [ + Order.new(item_id: 3), # Changed to Juice + Order.new(item_id: 1) # Changed to Coffee + ] + 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(:item_id).select(options: item_options) + end + end + + # First order should now have item_id=3 (Juice) selected + first_select = html.match(/]*id="user_orders_0_item_id"[^>]*>.*?<\/select>/m)[0] + expect(first_select).to include('') + + # Second order should now have item_id=1 (Coffee) selected + second_select = html.match(/]*id="user_orders_1_item_id"[^>]*>.*?<\/select>/m)[0] + expect(second_select).to include('') + end + end + + describe "multiple select in collection" do + let(:initial_orders) do + [ + Order.new(tag_ids: [1, 3]), + Order.new(tag_ids: [2]) + ] + end + let(:model) do + User.new(first_name: "Test", email: "test@example.com").tap do |user| + orders_list = initial_orders + 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") } + let(:tag_options) { [[1, "Ruby"], [2, "Rails"], [3, "Phlex"]] } + + it "renders multiple select with correct field names" do + html = render(form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:tag_ids).select( + options: tag_options, + multiple: true + ) + end + end + + # Multiple select in collection should use [index][field_name][] notation + expect(html).to include('name="user[orders][0][tag_ids][]"') + expect(html).to include('name="user[orders][1][tag_ids][]"') + # Should include multiple attribute + expect(html.scan(/multiple/).count).to eq(2) + end + + it "renders hidden inputs for empty submissions" do + html = render(form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:tag_ids).select( + options: tag_options, + multiple: true + ) + end + end + + # Should have hidden inputs before each select + expect(html).to include('') + expect(html).to include('') + end + + it "pre-selects multiple options based on array values" do + html = render(form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:tag_ids).select( + options: tag_options, + multiple: true + ) + end + end + + # First order should have Ruby (1) and Phlex (3) selected + # Extract just the first select element for testing + first_select = html.match(/]*id="user_orders_0_tag_ids"[^>]*>.*?<\/select>/m)[0] + expect(first_select).to include('') + expect(first_select).to include('') + # First order should NOT have Rails (2) selected + expect(first_select).to include('') + expect(first_select).not_to include('') + + # Second order should have Rails (2) selected + second_select = html.match(/]*id="user_orders_1_tag_ids"[^>]*>.*?<\/select>/m)[0] + expect(second_select).to include('') + end + + it "works with submitted params for multiple select" do + # Simulate Rails params after form submission + submitted_model = User.new(first_name: "Test", email: "test@example.com").tap do |user| + user.define_singleton_method(:orders) do + [ + Order.new(tag_ids: [2, 3]), # Changed to Rails + Phlex + Order.new(tag_ids: [1, 2, 3]) # Changed to all three + ] + 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(:tag_ids).select( + options: tag_options, + multiple: true + ) + end + end + + # First order should now have Rails (2) and Phlex (3) selected + first_select = html.match(/]*id="user_orders_0_tag_ids"[^>]*>.*?<\/select>/m)[0] + expect(first_select).to include('') + expect(first_select).to include('') + + # Second order should have all three selected + second_select = html.match(/]*id="user_orders_1_tag_ids"[^>]*>.*?<\/select>/m)[0] + expect(second_select).to include('') + expect(second_select).to include('') + expect(second_select).to include('') + end + end +end From 8218f40e14d674e9285489003fafe65ad565952b Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Tue, 18 Nov 2025 16:26:51 -0800 Subject: [PATCH 08/15] remove `include_blank`, update README --- README.md | 11 +- lib/superform/rails/components/select.rb | 20 ++-- lib/superform/rails/field.rb | 16 +-- .../select_collection_integration_spec.rb | 14 +-- .../superform/rails/components/select_spec.rb | 105 +++--------------- 5 files changed, 41 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 1d038e8..eabee17 100644 --- a/README.md +++ b/README.md @@ -362,10 +362,10 @@ class SignupForm < Components::Form end end - # Select with include_blank: true adds a blank option at the start + # Pass nil as first argument to add a blank option at the start div do field(:country).label { "Select your country" } - field(:country).select([[1, "USA"], [2, "Canada"], [3, "Mexico"]], include_blank: true) + field(:country).select(nil, [1, "USA"], [2, "Canada"], [3, "Mexico"]) end # Multiple select with multiple: true @@ -380,13 +380,12 @@ class SignupForm < Components::Form ) end - # Combine multiple: true with include_blank: true + # Combine multiple: true with nil for blank option div do field(:tag_ids).label { "Select tags (optional)" } field(:tag_ids).select( - [[1, "Ruby"], [2, "Rails"], [3, "Phlex"]], - multiple: true, - include_blank: true + nil, [1, "Ruby"], [2, "Rails"], [3, "Phlex"], + multiple: true ) end diff --git a/lib/superform/rails/components/select.rb b/lib/superform/rails/components/select.rb index 606fb41..abcd76a 100644 --- a/lib/superform/rails/components/select.rb +++ b/lib/superform/rails/components/select.rb @@ -4,26 +4,24 @@ module Components class Select < Field def initialize( *, - options: nil, + options: [], collection: nil, multiple: false, - include_blank: false, **, & ) super(*, **, &) # Handle deprecated collection parameter - if collection && !options + if collection && options.empty? warn "[DEPRECATION] Superform::Rails::Components::Select: " \ - "`collection:` parameter is deprecated. " \ - "Use `options:` instead." + "`collection:` keyword is deprecated and will be removed. " \ + "Use positional arguments instead: field.select([1, 'A'], [2, 'B'])" options = collection end - @options = options || [] + @options = options @multiple = multiple - @include_blank = include_blank end def view_template(&block) @@ -38,8 +36,12 @@ def view_template(&block) select(**attributes, &block) else select(**attributes) do - blank_option if @include_blank - options(*@options) + # If first option is nil, render a blank option + include_blank = @options.first.nil? + filtered_options = include_blank ? @options[1..] : @options + + blank_option if include_blank + options(*filtered_options) end end end diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index 2a091fc..2b3ffbb 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -46,26 +46,14 @@ def textarea(**attributes) end def select(*options, **attributes, &) - # Extract select-specific parameters from attributes if passed as keyword arguments - # Note: positional args are the preferred API - keyword form is for internal use - - # Handle deprecated collection parameter - if attributes.key?(:collection) && !attributes.key?(:options) - warn "[DEPRECATION] Superform::Rails::Field#select: " \ - "`collection:` parameter is deprecated. " \ - "Use `options:` instead." - attributes[:options] = attributes.delete(:collection) - end - - options = attributes.delete(:options) if options.empty? && attributes.key?(:options) + # Extract select-specific parameters from attributes multiple = attributes.delete(:multiple) || false - include_blank = attributes.delete(:include_blank) || false + Components::Select.new( field, attributes:, options:, multiple:, - include_blank:, & ) end diff --git a/spec/superform/rails/components/select_collection_integration_spec.rb b/spec/superform/rails/components/select_collection_integration_spec.rb index a3cff4a..7a80555 100644 --- a/spec/superform/rails/components/select_collection_integration_spec.rb +++ b/spec/superform/rails/components/select_collection_integration_spec.rb @@ -44,7 +44,7 @@ class Order < ActiveRecord::Base html = render(form) do |f| orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| - f.render order_namespace.field(:item_id).select(options: item_options) + f.render order_namespace.field(:item_id).select(*item_options) end end @@ -57,7 +57,7 @@ class Order < ActiveRecord::Base html = render(form) do |f| orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| - f.render order_namespace.field(:item_id).select(options: item_options) + f.render order_namespace.field(:item_id).select(*item_options) end end @@ -85,7 +85,7 @@ class Order < ActiveRecord::Base html = render(submitted_form) do |f| orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| - f.render order_namespace.field(:item_id).select(options: item_options) + f.render order_namespace.field(:item_id).select(*item_options) end end @@ -121,7 +121,7 @@ class Order < ActiveRecord::Base orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| f.render order_namespace.field(:tag_ids).select( - options: tag_options, + *tag_options, multiple: true ) end @@ -139,7 +139,7 @@ class Order < ActiveRecord::Base orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| f.render order_namespace.field(:tag_ids).select( - options: tag_options, + *tag_options, multiple: true ) end @@ -155,7 +155,7 @@ class Order < ActiveRecord::Base orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| f.render order_namespace.field(:tag_ids).select( - options: tag_options, + *tag_options, multiple: true ) end @@ -191,7 +191,7 @@ class Order < ActiveRecord::Base orders_collection = f.collection(:orders) orders_collection.each do |order_namespace| f.render order_namespace.field(:tag_ids).select( - options: tag_options, + *tag_options, multiple: true ) end diff --git a/spec/superform/rails/components/select_spec.rb b/spec/superform/rails/components/select_spec.rb index e650805..5009aa5 100644 --- a/spec/superform/rails/components/select_spec.rb +++ b/spec/superform/rails/components/select_spec.rb @@ -9,7 +9,7 @@ end let(:options) { [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] } let(:component) do - described_class.new(field, attributes: attributes, options: options) + described_class.new(field, attributes: attributes, options:) end let(:attributes) { {} } @@ -81,7 +81,7 @@ described_class.new( field, attributes: attributes, - options: options, + options:, multiple: true ) end @@ -124,7 +124,7 @@ described_class.new( field, attributes: attributes, - options: options, + options:, multiple: true ) end @@ -158,13 +158,13 @@ end end - describe 'with include_blank: true' do + describe 'with nil as first option (blank option)' do + let(:options_with_blank) { [nil, *options] } let(:component) do described_class.new( field, attributes: attributes, - options: options, - include_blank: true + options: options_with_blank ) end @@ -212,7 +212,7 @@ described_class.new( role_ids_field, attributes: attributes, - options: options, + options:, multiple: true ) end @@ -246,14 +246,14 @@ end end - describe 'with both multiple and include_blank' do + describe 'with both multiple and nil first option (blank)' do + let(:options_with_blank) { [nil, *options] } let(:component) do described_class.new( field, attributes: attributes, - options: options, - multiple: true, - include_blank: true + options: options_with_blank, + multiple: true ) end @@ -283,16 +283,16 @@ end end - context 'with options keyword argument' do + context 'with positional arguments' do subject do render( form_field.select( - options: [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] + [1, 'Admin'], [2, 'Editor'], [3, 'Viewer'] ) ) end - it 'renders select with options from options kwarg' do + it 'renders select with options from positional args' do expect(subject).to include('>Admin') expect(subject).to include('>Editor') expect(subject).to include('>Viewer') @@ -303,13 +303,13 @@ subject do render( form_field.select( - options: [[1, 'Admin'], [2, 'Editor']], + [1, 'Admin'], [2, 'Editor'], multiple: true ) ) end - it 'renders multiple select with options kwarg' do + it 'renders multiple select with positional args' do expect(subject).to include('multiple') expect(subject).to include('name="role_ids[]"') expect(subject).to include('>Admin') @@ -365,78 +365,5 @@ expect(subject).to match(/') - expect(result).to include('>Editor') - expect(result).to include('>Viewer') - end - end - - context 'using deprecated collection keyword in field helper' do - let(:form_field) do - Superform::Rails::Field.new(:role_ids, parent: nil, object: object) - end - - it 'shows deprecation warning' do - expect(form_field).to receive(:warn).with( - "[DEPRECATION] Superform::Rails::Field#select: " \ - "`collection:` parameter is deprecated. " \ - "Use `options:` instead." - ) - form_field.select(collection: [[1, 'Admin'], [2, 'Editor']]) - end - - it 'still renders select correctly' do - # Suppress deprecation warning for this test - allow(form_field).to receive(:warn) - result = render( - form_field.select(collection: [[1, 'Admin'], [2, 'Editor']]) - ) - expect(result).to include('>Admin') - expect(result).to include('>Editor') - end - end - - context 'when both options and collection are provided' do - let(:component) do - described_class.new( - field, - attributes: attributes, - options: [[1, 'Admin']], - collection: [[2, 'Editor']] - ) - end - - it 'does not show deprecation warning' do - expect_any_instance_of(described_class).not_to receive(:warn) - component - end - - it 'uses options parameter (takes precedence)' do - result = render(component) - expect(result).to include('>Admin') - expect(result).not_to include('>Editor') - end - end - end end # rubocop:enable Metrics/BlockLength From 5a61bd55b153611df338d9b0239e1cfd758bbe09 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Sat, 29 Nov 2025 23:19:17 -0800 Subject: [PATCH 09/15] Remove Ruby version bump (moved to separate PR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- superform.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superform.gemspec b/superform.gemspec index ca8c56b..010d637 100644 --- a/superform.gemspec +++ b/superform.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = "A better way to customize and build forms for your Rails application" spec.homepage = "https://github.com/rubymonolith/superform" spec.license = "MIT" - spec.required_ruby_version = ">= 2.7.0" + spec.required_ruby_version = ">= 2.6.0" spec.metadata["allowed_push_host"] = "https://rubygems.org" From 0b511ad7893c156327d7bcf117b102ded2972354 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Sat, 29 Nov 2025 23:28:43 -0800 Subject: [PATCH 10/15] Move Array allocation outside loop in select options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids allocating a new array on each iteration when checking if a value is selected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/superform/rails/components/select.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/superform/rails/components/select.rb b/lib/superform/rails/components/select.rb index abcd76a..27d8b59 100644 --- a/lib/superform/rails/components/select.rb +++ b/lib/superform/rails/components/select.rb @@ -47,10 +47,10 @@ def view_template(&block) end def options(*collection) + # Handle both single values and arrays (for multiple selects) + selected_values = Array(field.value) map_options(collection).each do |key, value| - # Handle both single values and arrays (for multiple selects) - selected = Array(field.value).include?(key) - option(selected: selected, value: key) { value } + option(selected: selected_values.include?(key), value: key) { value } end end From 6e0337125c62e50f13877e718f23ef6bc778ae5c Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Sat, 29 Nov 2025 23:32:32 -0800 Subject: [PATCH 11/15] Allow nil (blank option) at any position in options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously nil was only handled as the first option. Now nil renders as a blank option wherever it appears in the collection. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/superform/rails/components/select.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/superform/rails/components/select.rb b/lib/superform/rails/components/select.rb index 27d8b59..c42e078 100644 --- a/lib/superform/rails/components/select.rb +++ b/lib/superform/rails/components/select.rb @@ -36,12 +36,7 @@ def view_template(&block) select(**attributes, &block) else select(**attributes) do - # If first option is nil, render a blank option - include_blank = @options.first.nil? - filtered_options = include_blank ? @options[1..] : @options - - blank_option if include_blank - options(*filtered_options) + options(*@options) end end end @@ -50,7 +45,11 @@ def options(*collection) # Handle both single values and arrays (for multiple selects) selected_values = Array(field.value) map_options(collection).each do |key, value| - option(selected: selected_values.include?(key), value: key) { value } + if key.nil? + blank_option + else + option(selected: selected_values.include?(key), value: key) { value } + end end end From f43aa079a5b48d5a4c42568adf18ae00cac51398 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Sat, 29 Nov 2025 23:37:26 -0800 Subject: [PATCH 12/15] Use explicit multiple: keyword in field#select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleaner than extracting from attributes hash. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/superform/rails/field.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index 2b3ffbb..57904ab 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -45,10 +45,7 @@ def textarea(**attributes) Components::Textarea.new(field, attributes:) end - def select(*options, **attributes, &) - # Extract select-specific parameters from attributes - multiple = attributes.delete(:multiple) || false - + def select(*options, multiple: false, **attributes, &) Components::Select.new( field, attributes:, From 270cf1599f552f58d78c648cf871117505548a13 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Sat, 29 Nov 2025 23:40:47 -0800 Subject: [PATCH 13/15] Restore capitalized Field() in README examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capitalized Field is intentional - it's the Phlex Kit syntax. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 94 +++++++++++++++++++++++++++---------------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index eabee17..c1121c0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ * **Works beautifully with ERB.** Start using Superform in your existing Rails app without changing a single ERB template. All the power, zero migration pain. -* **Concise field helpers.** `field(:publish_at).date`, `field(:email).email`, `field(:price).number` — intuitive methods that generate the right input types with proper validation. +* **Concise field helpers.** `Field(:publish_at).date`, `Field(:email).email`, `field(:price).number` — intuitive methods that generate the right input types with proper validation. * **RESTful controller helpers** Superform's `save` and `save!` methods work exactly like ActiveRecord, making controller code predictable and Rails-like. @@ -63,10 +63,10 @@ You probably want to use the same form for creating and editing resources. In Su # app/views/posts/form.rb class Views::Posts::Form < Components::Form def view_template - field(:title).text - field(:body).textarea(rows: 10) - field(:publish_at).date - field(:featured).checkbox + Field(:title).text + Field(:body).textarea(rows: 10) + Field(:publish_at).date + Field(:featured).checkbox submit end end @@ -118,16 +118,16 @@ Superform includes helpers for all HTML5 input types: ```ruby class UserForm < Components::Form def view_template - field(:email).email # type="email" - field(:password).password # type="password" - field(:website).url # type="url" - field(:phone).tel # type="tel" - field(:age).number(min: 18) # type="number" - field(:birthday).date # type="date" - field(:appointment).datetime # type="datetime-local" - field(:favorite_color).color # type="color" - field(:bio).textarea(rows: 5) - field(:terms).checkbox + Field(:email).email # type="email" + Field(:password).password # type="password" + Field(:website).url # type="url" + Field(:phone).tel # type="tel" + Field(:age).number(min: 18) # type="number" + Field(:birthday).date # type="date" + Field(:appointment).datetime # type="datetime-local" + Field(:favorite_color).color # type="color" + Field(:bio).textarea(rows: 5) + Field(:terms).checkbox submit end end @@ -142,14 +142,14 @@ class Views::Posts::Form < Components::Form def view_template div(class: "form-section") do h2 { "Post Details" } - field(:title).text(class: "form-control") - field(:body).textarea(class: "form-control", rows: 10) + Field(:title).text(class: "form-control") + Field(:body).textarea(class: "form-control", rows: 10) end div(class: "form-section") do h2 { "Publishing" } - field(:publish_at).date(class: "form-control") - field(:featured).checkbox(class: "form-check-input") + Field(:publish_at).date(class: "form-control") + Field(:featured).checkbox(class: "form-check-input") end div(class: "form-actions") do @@ -204,8 +204,8 @@ That looks like a LOT of code, and it is, but look at how easy it is to create f # ./app/views/users/form.rb class Users::Form < Components::Form def view_template(&) - labeled field(:name).input - labeled field(:email).input(type: :email) + labeled Field(:name).input + labeled Field(:email).input(type: :email) submit "Sign up" end @@ -232,19 +232,19 @@ class AccountForm < Superform::Rails::Form # Account#owner returns a single object namespace :owner do |owner| # Renders input with the name `account[owner][name]` - owner.field(:name).text + owner.Field(:name).text # Renders input with the name `account[owner][email]` - owner.field(:email).email + owner.Field(:email).email end # Account#members returns a collection of objects collection(:members).each do |member| # Renders input with the name `account[members][0][name]`, # `account[members][1][name]`, ... - member.field(:name).input + member.Field(:name).input # Renders input with the name `account[members][0][email]`, # `account[members][1][email]`, ... - member.field(:email).input(type: :email) + member.Field(:email).input(type: :email) # Member#permissions returns an array of values like # ["read", "write", "delete"]. @@ -278,7 +278,7 @@ By default Superform namespaces a form based on the ActiveModel model name param ```ruby class UserForm < Superform::Rails::Form def view_template - render field(:email).input + render Field(:email).input end end @@ -294,7 +294,7 @@ To customize the form namespace, like an ActiveRecord model nested within a modu ```ruby class UserForm < Superform::Rails::Form def view_template - render field(:email).input + render Field(:email).input end def key @@ -320,10 +320,10 @@ In practice, many of the calls below you'd put inside of a method. This cuts dow class SignupForm < Components::Form def view_template # The most basic type of input, which will be autofocused. - field(:name).input.focus + Field(:name).input.focus # Input field with a lot more options on it. - field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) + Field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) # You can put fields in a block if that's your thing. field(:reason) do |f| @@ -338,8 +338,8 @@ class SignupForm < Components::Form # - A single value: "text" renders # - nil: renders an empty div do - field(:contact).label { "Would you like us to spam you to death?" } - field(:contact).select( + Field(:contact).label { "Would you like us to spam you to death?" } + Field(:contact).select( [true, "Yes"], # [false, "No"], # "Hell no", # @@ -348,8 +348,8 @@ class SignupForm < Components::Form end div do - field(:source).label { "How did you hear about us?" } - field(:source).select do |s| + Field(:source).label { "How did you hear about us?" } + Field(:source).select do |s| # Renders a blank option. s.blank_option # Pretend WebSources is an ActiveRecord scope with a "Social" category that has "Facebook, X, etc" @@ -364,8 +364,8 @@ class SignupForm < Components::Form # Pass nil as first argument to add a blank option at the start div do - field(:country).label { "Select your country" } - field(:country).select(nil, [1, "USA"], [2, "Canada"], [3, "Mexico"]) + Field(:country).label { "Select your country" } + Field(:country).select(nil, [1, "USA"], [2, "Canada"], [3, "Mexico"]) end # Multiple select with multiple: true @@ -373,8 +373,8 @@ class SignupForm < Components::Form # - Appends [] to the field name (role_ids becomes role_ids[]) # - Includes a hidden input to handle empty submissions div do - field(:role_ids).label { "Select roles" } - field(:role_ids).select( + Field(:role_ids).label { "Select roles" } + Field(:role_ids).select( [[1, "Admin"], [2, "Editor"], [3, "Viewer"]], multiple: true ) @@ -382,8 +382,8 @@ class SignupForm < Components::Form # Combine multiple: true with nil for blank option div do - field(:tag_ids).label { "Select tags (optional)" } - field(:tag_ids).select( + Field(:tag_ids).label { "Select tags (optional)" } + Field(:tag_ids).select( nil, [1, "Ruby"], [2, "Rails"], [3, "Phlex"], multiple: true ) @@ -393,15 +393,15 @@ class SignupForm < Components::Form # The relation is passed as a single argument (not splatted) # OptionMapper extracts the primary key and joins other attributes for the label div do - field(:author_id).label { "Select author" } + Field(:author_id).label { "Select author" } # For User.select(:id, :name), renders # where id=1 is the primary key and "Alice" is the name attribute - field(:author_id).select(User.select(:id, :name)) + Field(:author_id).select(User.select(:id, :name)) end div do - field(:agreement).label { "Check this box if you agree to give us your first born child" } - field(:agreement).checkbox(checked: true) + Field(:agreement).label { "Check this box if you agree to give us your first born child" } + Field(:agreement).checkbox(checked: true) end render button { "Submit" } @@ -416,9 +416,9 @@ If you want to add file upload fields to your form you will need to initialize y class User::ImageForm < Components::Form def view_template # render label - field(:image).label { "Choose file" } + Field(:image).label { "Choose file" } # render file input with accept attribute for png and jpeg images - field(:image).input(type: "file", accept: "image/png, image/jpeg") + Field(:image).input(type: "file", accept: "image/png, image/jpeg") end end @@ -454,8 +454,8 @@ Then, just like you did in your Erb, you create the form: ```ruby class Admin::Users::Form < AdminForm def view_template(&) - labeled field(:name).tooltip_input - labeled field(:email).tooltip_input(type: :email) + labeled Field(:name).tooltip_input + labeled Field(:email).tooltip_input(type: :email) submit "Save" end From 273729f44fecdc51c11f7d06c102b7c9fa0ba7eb Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Sat, 29 Nov 2025 23:40:47 -0800 Subject: [PATCH 14/15] Restore capitalized Field() in README examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capitalized Field is intentional - it's the Phlex Kit syntax. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 92 +++++++++++++++++++++++++++---------------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index eabee17..c15bded 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,10 @@ You probably want to use the same form for creating and editing resources. In Su # app/views/posts/form.rb class Views::Posts::Form < Components::Form def view_template - field(:title).text - field(:body).textarea(rows: 10) - field(:publish_at).date - field(:featured).checkbox + Field(:title).text + Field(:body).textarea(rows: 10) + Field(:publish_at).date + Field(:featured).checkbox submit end end @@ -118,16 +118,16 @@ Superform includes helpers for all HTML5 input types: ```ruby class UserForm < Components::Form def view_template - field(:email).email # type="email" - field(:password).password # type="password" - field(:website).url # type="url" - field(:phone).tel # type="tel" - field(:age).number(min: 18) # type="number" - field(:birthday).date # type="date" - field(:appointment).datetime # type="datetime-local" - field(:favorite_color).color # type="color" - field(:bio).textarea(rows: 5) - field(:terms).checkbox + Field(:email).email # type="email" + Field(:password).password # type="password" + Field(:website).url # type="url" + Field(:phone).tel # type="tel" + Field(:age).number(min: 18) # type="number" + Field(:birthday).date # type="date" + Field(:appointment).datetime # type="datetime-local" + Field(:favorite_color).color # type="color" + Field(:bio).textarea(rows: 5) + Field(:terms).checkbox submit end end @@ -142,14 +142,14 @@ class Views::Posts::Form < Components::Form def view_template div(class: "form-section") do h2 { "Post Details" } - field(:title).text(class: "form-control") - field(:body).textarea(class: "form-control", rows: 10) + Field(:title).text(class: "form-control") + Field(:body).textarea(class: "form-control", rows: 10) end div(class: "form-section") do h2 { "Publishing" } - field(:publish_at).date(class: "form-control") - field(:featured).checkbox(class: "form-check-input") + Field(:publish_at).date(class: "form-control") + Field(:featured).checkbox(class: "form-check-input") end div(class: "form-actions") do @@ -204,8 +204,8 @@ That looks like a LOT of code, and it is, but look at how easy it is to create f # ./app/views/users/form.rb class Users::Form < Components::Form def view_template(&) - labeled field(:name).input - labeled field(:email).input(type: :email) + labeled Field(:name).input + labeled Field(:email).input(type: :email) submit "Sign up" end @@ -232,19 +232,19 @@ class AccountForm < Superform::Rails::Form # Account#owner returns a single object namespace :owner do |owner| # Renders input with the name `account[owner][name]` - owner.field(:name).text + owner.Field(:name).text # Renders input with the name `account[owner][email]` - owner.field(:email).email + owner.Field(:email).email end # Account#members returns a collection of objects collection(:members).each do |member| # Renders input with the name `account[members][0][name]`, # `account[members][1][name]`, ... - member.field(:name).input + member.Field(:name).input # Renders input with the name `account[members][0][email]`, # `account[members][1][email]`, ... - member.field(:email).input(type: :email) + member.Field(:email).input(type: :email) # Member#permissions returns an array of values like # ["read", "write", "delete"]. @@ -278,7 +278,7 @@ By default Superform namespaces a form based on the ActiveModel model name param ```ruby class UserForm < Superform::Rails::Form def view_template - render field(:email).input + render Field(:email).input end end @@ -294,7 +294,7 @@ To customize the form namespace, like an ActiveRecord model nested within a modu ```ruby class UserForm < Superform::Rails::Form def view_template - render field(:email).input + render Field(:email).input end def key @@ -320,10 +320,10 @@ In practice, many of the calls below you'd put inside of a method. This cuts dow class SignupForm < Components::Form def view_template # The most basic type of input, which will be autofocused. - field(:name).input.focus + Field(:name).input.focus # Input field with a lot more options on it. - field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) + Field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) # You can put fields in a block if that's your thing. field(:reason) do |f| @@ -338,8 +338,8 @@ class SignupForm < Components::Form # - A single value: "text" renders # - nil: renders an empty div do - field(:contact).label { "Would you like us to spam you to death?" } - field(:contact).select( + Field(:contact).label { "Would you like us to spam you to death?" } + Field(:contact).select( [true, "Yes"], # [false, "No"], # "Hell no", # @@ -348,8 +348,8 @@ class SignupForm < Components::Form end div do - field(:source).label { "How did you hear about us?" } - field(:source).select do |s| + Field(:source).label { "How did you hear about us?" } + Field(:source).select do |s| # Renders a blank option. s.blank_option # Pretend WebSources is an ActiveRecord scope with a "Social" category that has "Facebook, X, etc" @@ -364,8 +364,8 @@ class SignupForm < Components::Form # Pass nil as first argument to add a blank option at the start div do - field(:country).label { "Select your country" } - field(:country).select(nil, [1, "USA"], [2, "Canada"], [3, "Mexico"]) + Field(:country).label { "Select your country" } + Field(:country).select(nil, [1, "USA"], [2, "Canada"], [3, "Mexico"]) end # Multiple select with multiple: true @@ -373,8 +373,8 @@ class SignupForm < Components::Form # - Appends [] to the field name (role_ids becomes role_ids[]) # - Includes a hidden input to handle empty submissions div do - field(:role_ids).label { "Select roles" } - field(:role_ids).select( + Field(:role_ids).label { "Select roles" } + Field(:role_ids).select( [[1, "Admin"], [2, "Editor"], [3, "Viewer"]], multiple: true ) @@ -382,8 +382,8 @@ class SignupForm < Components::Form # Combine multiple: true with nil for blank option div do - field(:tag_ids).label { "Select tags (optional)" } - field(:tag_ids).select( + Field(:tag_ids).label { "Select tags (optional)" } + Field(:tag_ids).select( nil, [1, "Ruby"], [2, "Rails"], [3, "Phlex"], multiple: true ) @@ -393,15 +393,15 @@ class SignupForm < Components::Form # The relation is passed as a single argument (not splatted) # OptionMapper extracts the primary key and joins other attributes for the label div do - field(:author_id).label { "Select author" } + Field(:author_id).label { "Select author" } # For User.select(:id, :name), renders # where id=1 is the primary key and "Alice" is the name attribute - field(:author_id).select(User.select(:id, :name)) + Field(:author_id).select(User.select(:id, :name)) end div do - field(:agreement).label { "Check this box if you agree to give us your first born child" } - field(:agreement).checkbox(checked: true) + Field(:agreement).label { "Check this box if you agree to give us your first born child" } + Field(:agreement).checkbox(checked: true) end render button { "Submit" } @@ -416,9 +416,9 @@ If you want to add file upload fields to your form you will need to initialize y class User::ImageForm < Components::Form def view_template # render label - field(:image).label { "Choose file" } + Field(:image).label { "Choose file" } # render file input with accept attribute for png and jpeg images - field(:image).input(type: "file", accept: "image/png, image/jpeg") + Field(:image).input(type: "file", accept: "image/png, image/jpeg") end end @@ -454,8 +454,8 @@ Then, just like you did in your Erb, you create the form: ```ruby class Admin::Users::Form < AdminForm def view_template(&) - labeled field(:name).tooltip_input - labeled field(:email).tooltip_input(type: :email) + labeled Field(:name).tooltip_input + labeled Field(:email).tooltip_input(type: :email) submit "Save" end From e1b4bc497e33c6dfd29c680e9619a9e0406463ef Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Sun, 30 Nov 2025 00:10:28 -0800 Subject: [PATCH 15/15] Use dom.array_name --- lib/superform/dom.rb | 6 ++++++ lib/superform/rails/components/select.rb | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/superform/dom.rb b/lib/superform/dom.rb index befaf14..e74c12b 100644 --- a/lib/superform/dom.rb +++ b/lib/superform/dom.rb @@ -29,6 +29,12 @@ def name names.map { |name| "[#{name}]" }.unshift(root).join end + # Returns the name with `[]` appended for array/multiple value fields. + # Used by multiple selects, checkbox groups, etc. + def array_name + "#{name}[]" + end + # Emit the id, name, and value in an HTML tag-ish that doesnt have an element. def inspect "" diff --git a/lib/superform/rails/components/select.rb b/lib/superform/rails/components/select.rb index c42e078..79425c0 100644 --- a/lib/superform/rails/components/select.rb +++ b/lib/superform/rails/components/select.rb @@ -28,7 +28,7 @@ def view_template(&block) # Hidden input ensures a value is sent even when all options are # deselected in a multiple select if @multiple - hidden_name = field.parent.is_a?(Superform::Field) ? dom.name : "#{dom.name}[]" + hidden_name = field.parent.is_a?(Superform::Field) ? dom.name : dom.array_name input(type: "hidden", name: hidden_name, value: "") end @@ -75,7 +75,7 @@ def field_attributes if @multiple # Only append [] if the field doesn't already have a Field parent # (which would mean it's already in a collection and has [] notation) - name = field.parent.is_a?(Superform::Field) ? attrs[:name] : "#{attrs[:name]}[]" + name = field.parent.is_a?(Superform::Field) ? attrs[:name] : dom.array_name attrs.merge(multiple: true, name: name) else attrs