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..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. @@ -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 @@ -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 @@ -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 @@ -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,6 +362,43 @@ class SignupForm < Components::Form end end + # 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"]) + end + + # 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( + [[1, "Admin"], [2, "Editor"], [3, "Viewer"]], + multiple: true + ) + end + + # Combine multiple: true with nil for blank option + div do + Field(:tag_ids).label { "Select tags (optional)" } + Field(:tag_ids).select( + nil, [1, "Ruby"], [2, "Rails"], [3, "Phlex"], + multiple: true + ) + end + + # 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" } + # 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 Field(:agreement).label { "Check this box if you agree to give us your first born child" } Field(:agreement).checkbox(checked: true) @@ -414,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 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 8979591..79425c0 100644 --- a/lib/superform/rails/components/select.rb +++ b/lib/superform/rails/components/select.rb @@ -2,22 +2,54 @@ module Superform module Rails module Components class Select < Field - def initialize(*, collection: [], **, &) + def initialize( + *, + options: [], + collection: nil, + multiple: false, + **, + & + ) super(*, **, &) - @collection = collection + + # Handle deprecated collection parameter + if collection && options.empty? + warn "[DEPRECATION] Superform::Rails::Components::Select: " \ + "`collection:` keyword is deprecated and will be removed. " \ + "Use positional arguments instead: field.select([1, 'A'], [2, 'B'])" + options = collection + end + + @options = options + @multiple = multiple 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 + hidden_name = field.parent.is_a?(Superform::Field) ? dom.name : dom.array_name + input(type: "hidden", name: hidden_name, value: "") + end + if block_given? - select(**attributes, &options) + select(**attributes, &block) else - select(**attributes) { options(*@collection) } + select(**attributes) do + options(*@options) + end end 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| - option(selected: field.value == key, value: key) { value } + if key.nil? + blank_option + else + option(selected: selected_values.include?(key), value: key) { value } + end end end @@ -37,6 +69,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] : dom.array_name + attrs.merge(multiple: true, name: name) + else + attrs + end + end end end end diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index f82325f..57904ab 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -45,8 +45,14 @@ def textarea(**attributes) Components::Textarea.new(field, attributes:) end - def select(*collection, **attributes, &) - Components::Select.new(field, attributes:, collection:, &) + def select(*options, multiple: false, **attributes, &) + Components::Select.new( + field, + attributes:, + options:, + multiple:, + & + ) end def errors 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..7a80555 --- /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(*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(*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(*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( + *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( + *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( + *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( + *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 diff --git a/spec/superform/rails/components/select_spec.rb b/spec/superform/rails/components/select_spec.rb new file mode 100644 index 0000000..5009aa5 --- /dev/null +++ b/spec/superform/rails/components/select_spec.rb @@ -0,0 +1,369 @@ +# 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(:options) { [[1, 'Admin'], [2, 'Editor'], [3, 'Viewer']] } + let(:component) do + described_class.new(field, attributes: attributes, options:) + 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 + + it 'renders complete HTML structure' do + expect(subject).to eq( + '' + ) + end + end + + describe 'with selected value' do + let(:role_ids_value) { 2 } + + subject { render(component) } + + it 'marks the matching option as selected' do + expect(subject).to match( + /]*selected[^>]*value="2"[^>]*>Editor<\/option>/ + ) + end + + it 'does not mark other options as selected' do + expect(subject).not_to match( + /' \ + '' \ + '' \ + '' + ) + end + end + + describe 'with multiple: true and selected array values' do + let(:role_ids_value) { [1, 3] } + let(:component) do + described_class.new( + field, + attributes: attributes, + options:, + multiple: true + ) + end + + subject { render(component) } + + it 'marks all matching options as selected' do + expect(subject).to match( + /]*selected[^>]*value="1"[^>]*>Admin<\/option>/ + ) + expect(subject).to match( + /]*selected[^>]*value="3"[^>]*>Viewer<\/option>/ + ) + end + + it 'does not mark non-matching options as selected' do + expect(subject).not_to match( + /}) + end + + it 'renders blank option before collection options' do + expect(subject).to match( + %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 + let(:users_field) do + users_object = double('users', users: [{}, {}]) + Superform::Rails::Field.new( + :users, + parent: nil, + object: users_object, + value: [{}, {}] + ) + end + let(:user_collection) { users_field.collection } + let(:user_field) { user_collection.field } + let(:role_ids_field) do + role_object = double('user', role_ids: nil) + Superform::Rails::Field.new(:role_ids, parent: user_field, object: role_object) + end + let(:component) do + described_class.new( + role_ids_field, + attributes: attributes, + options:, + multiple: true + ) + end + + subject { render(component) } + + it 'does not append extra [] when parent is a Field' do + # The field name should be users[][] not users[][][] + # because role_ids key is excluded when parent is a Field + expect(subject).to include('name="users[][]"') + expect(subject).not_to include('name="users[][][]"') + end + + it 'still includes multiple attribute' do + expect(subject).to include('multiple') + end + + 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 nil first option (blank)' do + let(:options_with_blank) { [nil, *options] } + let(:component) do + described_class.new( + field, + attributes: attributes, + options: options_with_blank, + multiple: true + ) + end + + subject { render(component) } + + it 'renders both features correctly' do + expect(subject).to include('multiple') + expect(subject).to include('name="role_ids[]"') + expect(subject).to match(%r{]*selected[^>]*>}) + 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 positional 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 multiple: true keyword argument' do + subject do + render( + form_field.select( + [1, 'Admin'], [2, 'Editor'], + multiple: true + ) + ) + end + + 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') + expect(subject).to include('>Editor') + end + end + end + + describe '#blank_option' do + let(:component) do + described_class.new(field, attributes: attributes, options: []) + end + + context 'when field value is nil' do + let(:role_ids_value) { nil } + + it 'renders selected blank option' do + output = render(component, &:blank_option) + expect(output).to match(%r{]*selected[^>]*>}) + end + end + + context 'when field has a value' do + let(:role_ids_value) { 1 } + + it 'renders unselected blank option' do + output = render(component, &:blank_option) + expect(output).not_to match(/]*selected/) + 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(/