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..6f13971 100644 --- a/README.md +++ b/README.md @@ -359,11 +359,67 @@ class SignupForm < Components::Form end end + # Radio buttons - for single-select from multiple options + div do + Field(:plan).label(for: false) { "Choose your plan" } + # Pass options as positional arguments + Field(:plan).radio( + ["free", "Free Plan"], # + ["pro", "Pro Plan"], # + ["enterprise", "Enterprise"] # + ) + end + + # Or render individual radio buttons with custom markup + div do + Field(:gender).label(for: false) { "Gender" } + Field(:gender).radio do |r| + div { r.option("m") { "Male" } } + div { r.option("f") { "Female" } } + div { r.option("o") { "Other" } } + end + end + + # Boolean checkbox - single true/false toggle div do Field(:agreement).label { "Check this box if you agree to give us your first born child" } Field(:agreement).checkbox(checked: true) end + # Checkbox array - for multi-select from multiple options + div do + Field(:role_ids).label(for: false) { "Select your roles" } + # Pass options as positional arguments (similar to radio) + Field(:role_ids).checkbox( + [1, "Admin"], # + [2, "Editor"], # + [3, "Viewer"] # + ) + end + + # Or render individual checkboxes with custom markup + div do + Field(:feature_ids).label(for: false) { "Enable features" } + Field(:feature_ids).checkbox do |c| + div { c.option(1) { "Dark Mode" } } + div { c.option(2) { "Notifications" } } + div { c.option(3) { "Auto-save" } } + end + end + + # Both radio and checkbox support ActiveRecord relations + div do + Field(:category_id).label(for: false) { "Select category" } + # Automatically uses id as value and name as label + Field(:category_id).radio(Category.select(:id, :name)) + end + + div do + Field(:tag_ids).label(for: false) { "Select tags" } + # Automatically uses id as value and name as label + Field(:tag_ids).checkbox(Tag.select(:id, :name)) + end + render button { "Submit" } end end diff --git a/lib/superform/rails/components/checkbox.rb b/lib/superform/rails/components/checkbox.rb index 0c40f73..ba0d0e1 100644 --- a/lib/superform/rails/components/checkbox.rb +++ b/lib/superform/rails/components/checkbox.rb @@ -2,17 +2,76 @@ module Superform module Rails module Components class Checkbox < Field - def view_template(&) - # Rails has a hidden and checkbox input to deal with sending back a value - # to the server regardless of if the input is checked or not. - input(name: dom.name, type: :hidden, value: "0") - # The hard coded keys need to be in here so the user can't overrite them. - input(type: :checkbox, value: "1", **attributes) + def initialize(field, *option_list, **, &) + super(field, **, &) + @options = option_list end - def field_attributes - { id: dom.id, name: dom.name, checked: field.value } + def view_template(&block) + if array_mode? || block_given? + # Array mode: render multiple checkboxes + if block_given? + yield self + else + options(*@options) + end + else + # Boolean mode: single checkbox with hidden field + # Rails has a hidden and checkbox input to deal with sending back + # a value to the server regardless of if the input is checked or not. + input(name: dom.name, type: :hidden, value: "0") + # The hard coded keys need to be in here so the user can't overrite them. + input(type: :checkbox, value: "1", **attributes) + end end + + # Array mode methods + def options(*option_list) + map_options(option_list).each do |value, label| + option(value) { label } + end + end + + def option(value, &block) + label do + input( + **attributes, + type: :checkbox, + id: "#{dom.id}_#{value}", + name: "#{dom.name}[]", + value: value.to_s, + checked: checked_in_array?(value) + ) + plain(yield) if block_given? + end + end + + protected + def array_mode? + @options.any? + end + + def map_options(option_list) + OptionMapper.new(option_list) + end + + def checked_in_array?(value) + # Checkbox arrays are multi-select, so field.value should be an array + field_value = field.value + return false if field_value.nil? + + field_value = [field_value] unless field_value.is_a?(Array) + field_value.map(&:to_s).include?(value.to_s) + end + + def field_attributes + if array_mode? + # option method handles all attributes explicitly + {} + else + { id: dom.id, name: dom.name, checked: field.value } + end + end end end end diff --git a/lib/superform/rails/components/label.rb b/lib/superform/rails/components/label.rb index 40f853f..788f195 100644 --- a/lib/superform/rails/components/label.rb +++ b/lib/superform/rails/components/label.rb @@ -8,7 +8,19 @@ def view_template(&content) end def field_attributes - { for: dom.id } + # Only include 'for' attribute if explicitly provided or default + # Skip it if set to false/nil to avoid invalid HTML + attrs = {} + for_value = @attributes&.fetch(:for, :default) + + if for_value == :default + attrs[:for] = dom.id + elsif for_value + attrs[:for] = for_value + end + # If for_value is false/nil, skip the attribute entirely + + attrs end def label_text diff --git a/lib/superform/rails/components/radio.rb b/lib/superform/rails/components/radio.rb new file mode 100644 index 0000000..dbbcb8f --- /dev/null +++ b/lib/superform/rails/components/radio.rb @@ -0,0 +1,53 @@ +module Superform + module Rails + module Components + class Radio < Field + def initialize(field, *option_list, **, &) + super(field, **, &) + @options = option_list + end + + def view_template(&block) + if block_given? + yield self + else + options(*@options) + end + end + + def options(*option_list) + map_options(option_list).each do |value, label| + option(value) { label } + end + end + + def option(value, &block) + 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 + 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 + { name: dom.name } + end + end + end + end +end diff --git a/lib/superform/rails/field.rb b/lib/superform/rails/field.rb index f82325f..8d3a60e 100644 --- a/lib/superform/rails/field.rb +++ b/lib/superform/rails/field.rb @@ -33,8 +33,10 @@ def input(**attributes) Components::Input.new(field, attributes:) end - def checkbox(**attributes) - Components::Checkbox.new(field, attributes:) + def checkbox(*args, **attributes) + # Treat as collection if args provided (including single ActiveRecord::Relation) + # Otherwise treat as boolean checkbox (single true/false) + Components::Checkbox.new(field, *args, attributes:) end def label(**attributes, &) @@ -137,8 +139,22 @@ def file(*, **, &) input(*, **, type: :file, &) end - def radio(value, *, **, &) - input(*, **, type: :radio, value: value, &) + def radio(*args, **attributes, &block) + # 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, *args, attributes:, &block) + # 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, *args, attributes:, &block) + # No args but block given - allow custom rendering + elsif args.empty? && block + Components::Radio.new(field, attributes:, &block) + # No args or single non-collection arg - error + else + raise ArgumentError, + "radio requires multiple options (e.g., radio(['a', 'A'], ['b', 'B']))" + end end # Rails compatibility aliases diff --git a/spec/superform/rails/components/checkbox_collection_integration_spec.rb b/spec/superform/rails/components/checkbox_collection_integration_spec.rb new file mode 100644 index 0000000..f6ea79a --- /dev/null +++ b/spec/superform/rails/components/checkbox_collection_integration_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +RSpec.describe "Checkbox in Collection Integration", type: :view do + # Set up test database for collection models + before(:all) do + ActiveRecord::Schema.define do + create_table :pizza_orders, force: true do |t| + t.string :toppings + end + end unless ActiveRecord::Base.connection.table_exists?(:pizza_orders) + end + + after(:all) do + ActiveRecord::Schema.define do + drop_table :pizza_orders if ActiveRecord::Base.connection.table_exists?(:pizza_orders) + end + end + + # Test model for collection (array of objects) + class PizzaOrder < ActiveRecord::Base + self.table_name = "pizza_orders" + # Serialize toppings as array for multi-select checkboxes + serialize :toppings, coder: JSON + end + + describe "collection (array of objects with multi-select)" do + let(:initial_orders) do + [ + PizzaOrder.new(toppings: ["cheese", "pepperoni"]), + PizzaOrder.new(toppings: ["mushrooms"]) + ] + 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") } + let(:topping_options) do + [ + ["cheese", "Cheese"], + ["pepperoni", "Pepperoni"], + ["mushrooms", "Mushrooms"], + ["olives", "Olives"] + ] + end + + it "renders checkboxes 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(:toppings).checkbox(*topping_options) + end + end + + # Collection uses model_name[collection_name][index][field_name][] notation + # The final [] is for checkbox array submission + expect(html).to include('name="user[orders][0][toppings][]"') + expect(html).to include('name="user[orders][1][toppings][]"') + expect(html.scan(/type="checkbox"/).count).to eq(8) # 4 checkboxes × 2 orders + end + + it "pre-selects checkboxes 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(:toppings).checkbox(*topping_options) + end + end + + # Should have exactly 3 checked checkboxes total + # First order: cheese, pepperoni (2 checked) + # Second order: mushrooms (1 checked) + expect(html.scan(/checked/).count).to eq(3) + + # "cheese" should be checked (first order) + expect(html).to match(/]*id="user_orders_0_toppings_cheese"[^>]*checked/) + # "pepperoni" should be checked (first order) + expect(html).to match(/]*id="user_orders_0_toppings_pepperoni"[^>]*checked/) + # "mushrooms" should be checked (second order) + expect(html).to match(/]*id="user_orders_1_toppings_mushrooms"[^>]*checked/) + end + + it "does not check unselected options" do + html = render(form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:toppings).checkbox(*topping_options) + end + end + + # "olives" should not be checked in either order + expect(html).not_to match(/]*id="user_orders_0_toppings_olives"[^>]*checked/) + expect(html).not_to match(/]*id="user_orders_1_toppings_olives"[^>]*checked/) + + # "mushrooms" should not be checked in first order + expect(html).not_to match(/]*id="user_orders_0_toppings_mushrooms"[^>]*checked/) + end + + it "works with submitted params from collection" do + # Simulate Rails params after form submission + # Collection checkboxes submit as: + # { "user" => { "orders" => [ + # { "toppings" => ["olives", "mushrooms"] }, + # { "toppings" => ["cheese", "pepperoni", "olives"] } + # ] } } + submitted_model = User.new(first_name: "Test", email: "test@example.com").tap do |user| + user.define_singleton_method(:orders) do + [ + PizzaOrder.new(toppings: ["olives", "mushrooms"]), + PizzaOrder.new(toppings: ["cheese", "pepperoni", "olives"]) + ] + 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(:toppings).checkbox(*topping_options) + end + end + + # Should have exactly 5 checked checkboxes + # First order: olives, mushrooms (2 checked) + # Second order: cheese, pepperoni, olives (3 checked) + expect(html.scan(/checked/).count).to eq(5) + + # First order should have "olives" and "mushrooms" checked + expect(html).to match(/]*id="user_orders_0_toppings_olives"[^>]*checked/) + expect(html).to match(/]*id="user_orders_0_toppings_mushrooms"[^>]*checked/) + + # Second order should have "cheese", "pepperoni", "olives" checked + expect(html).to match(/]*id="user_orders_1_toppings_cheese"[^>]*checked/) + expect(html).to match(/]*id="user_orders_1_toppings_pepperoni"[^>]*checked/) + expect(html).to match(/]*id="user_orders_1_toppings_olives"[^>]*checked/) + end + + it "wraps each checkbox in a label for clickability" do + html = render(form) do |f| + orders_collection = f.collection(:orders) + orders_collection.each do |order_namespace| + f.render order_namespace.field(:toppings).checkbox(*topping_options) + end + end + + # Each checkbox should be wrapped in a label + # 4 options × 2 orders = 8 labels + expect(html.scan(/