Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -252,6 +256,7 @@ PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
x86_64-darwin-21
x86_64-linux

DEPENDENCIES
Expand Down
58 changes: 49 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 <option value="value">label</option>
# - A single value: "text" renders <option value="text">text</option>
# - nil: renders an empty <option></option>
div do
Field(:contact).label { "Would you like us to spam you to death?" }
Field(:contact).select(
Expand All @@ -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 <option value="1">Alice</option>
# 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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/superform/dom.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
"<id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"
Expand Down
56 changes: 50 additions & 6 deletions lib/superform/rails/components/select.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions lib/superform/rails/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading