Skip to content

Commit f7fd471

Browse files
authored
Merge pull request #64 from nimmolo/nimmo-improve-select
Add `multiple` keyword/functionality to `select`
2 parents 701e5ab + e1b4bc4 commit f7fd471

6 files changed

Lines changed: 694 additions & 17 deletions

File tree

README.md

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
* **Works beautifully with ERB.** Start using Superform in your existing Rails app without changing a single ERB template. All the power, zero migration pain.
88

9-
* **Concise field helpers.** `field(:publish_at).date`, `field(:email).email`, `field(:price).number` — intuitive methods that generate the right input types with proper validation.
9+
* **Concise field helpers.** `Field(:publish_at).date`, `Field(:email).email`, `field(:price).number` — intuitive methods that generate the right input types with proper validation.
1010

1111
* **RESTful controller helpers** Superform's `save` and `save!` methods work exactly like ActiveRecord, making controller code predictable and Rails-like.
1212

@@ -204,8 +204,8 @@ That looks like a LOT of code, and it is, but look at how easy it is to create f
204204
# ./app/views/users/form.rb
205205
class Users::Form < Components::Form
206206
def view_template(&)
207-
labeled field(:name).input
208-
labeled field(:email).input(type: :email)
207+
labeled Field(:name).input
208+
labeled Field(:email).input(type: :email)
209209

210210
submit "Sign up"
211211
end
@@ -252,7 +252,7 @@ class AccountForm < Superform::Rails::Form
252252
# Renders input with the name `account[members][0][permissions][]`,
253253
# `account[members][1][permissions][]`, ...
254254
render permission.label do
255-
plain permisson.value.humanize
255+
plain permission.value.humanize
256256
render permission.checkbox
257257
end
258258
end
@@ -278,7 +278,7 @@ By default Superform namespaces a form based on the ActiveModel model name param
278278
```ruby
279279
class UserForm < Superform::Rails::Form
280280
def view_template
281-
render field(:email).input
281+
render Field(:email).input
282282
end
283283
end
284284

@@ -294,7 +294,7 @@ To customize the form namespace, like an ActiveRecord model nested within a modu
294294
```ruby
295295
class UserForm < Superform::Rails::Form
296296
def view_template
297-
render field(:email).input
297+
render Field(:email).input
298298
end
299299

300300
def key
@@ -333,7 +333,10 @@ class SignupForm < Components::Form
333333
end
334334
end
335335

336-
# Let's get crazy with Selects. They can accept values as simple as 2 element arrays.
336+
# Selects accept options as positional arguments. Each option can be:
337+
# - A 2-element array: [value, label] renders <option value="value">label</option>
338+
# - A single value: "text" renders <option value="text">text</option>
339+
# - nil: renders an empty <option></option>
337340
div do
338341
Field(:contact).label { "Would you like us to spam you to death?" }
339342
Field(:contact).select(
@@ -359,6 +362,43 @@ class SignupForm < Components::Form
359362
end
360363
end
361364

365+
# Pass nil as first argument to add a blank option at the start
366+
div do
367+
Field(:country).label { "Select your country" }
368+
Field(:country).select(nil, [1, "USA"], [2, "Canada"], [3, "Mexico"])
369+
end
370+
371+
# Multiple select with multiple: true
372+
# - Adds the HTML 'multiple' attribute
373+
# - Appends [] to the field name (role_ids becomes role_ids[])
374+
# - Includes a hidden input to handle empty submissions
375+
div do
376+
Field(:role_ids).label { "Select roles" }
377+
Field(:role_ids).select(
378+
[[1, "Admin"], [2, "Editor"], [3, "Viewer"]],
379+
multiple: true
380+
)
381+
end
382+
383+
# Combine multiple: true with nil for blank option
384+
div do
385+
Field(:tag_ids).label { "Select tags (optional)" }
386+
Field(:tag_ids).select(
387+
nil, [1, "Ruby"], [2, "Rails"], [3, "Phlex"],
388+
multiple: true
389+
)
390+
end
391+
392+
# Select options can also be ActiveRecord relations
393+
# The relation is passed as a single argument (not splatted)
394+
# OptionMapper extracts the primary key and joins other attributes for the label
395+
div do
396+
Field(:author_id).label { "Select author" }
397+
# For User.select(:id, :name), renders <option value="1">Alice</option>
398+
# where id=1 is the primary key and "Alice" is the name attribute
399+
Field(:author_id).select(User.select(:id, :name))
400+
end
401+
362402
div do
363403
Field(:agreement).label { "Check this box if you agree to give us your first born child" }
364404
Field(:agreement).checkbox(checked: true)
@@ -414,8 +454,8 @@ Then, just like you did in your Erb, you create the form:
414454
```ruby
415455
class Admin::Users::Form < AdminForm
416456
def view_template(&)
417-
labeled field(:name).tooltip_input
418-
labeled field(:email).tooltip_input(type: :email)
457+
labeled Field(:name).tooltip_input
458+
labeled Field(:email).tooltip_input(type: :email)
419459

420460
submit "Save"
421461
end

lib/superform/dom.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ def name
2929
names.map { |name| "[#{name}]" }.unshift(root).join
3030
end
3131

32+
# Returns the name with `[]` appended for array/multiple value fields.
33+
# Used by multiple selects, checkbox groups, etc.
34+
def array_name
35+
"#{name}[]"
36+
end
37+
3238
# Emit the id, name, and value in an HTML tag-ish that doesnt have an element.
3339
def inspect
3440
"<id=#{id.inspect} name=#{name.inspect} value=#{value.inspect}/>"

lib/superform/rails/components/select.rb

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,54 @@ module Superform
22
module Rails
33
module Components
44
class Select < Field
5-
def initialize(*, collection: [], **, &)
5+
def initialize(
6+
*,
7+
options: [],
8+
collection: nil,
9+
multiple: false,
10+
**,
11+
&
12+
)
613
super(*, **, &)
7-
@collection = collection
14+
15+
# Handle deprecated collection parameter
16+
if collection && options.empty?
17+
warn "[DEPRECATION] Superform::Rails::Components::Select: " \
18+
"`collection:` keyword is deprecated and will be removed. " \
19+
"Use positional arguments instead: field.select([1, 'A'], [2, 'B'])"
20+
options = collection
21+
end
22+
23+
@options = options
24+
@multiple = multiple
825
end
926

10-
def view_template(&options)
27+
def view_template(&block)
28+
# Hidden input ensures a value is sent even when all options are
29+
# deselected in a multiple select
30+
if @multiple
31+
hidden_name = field.parent.is_a?(Superform::Field) ? dom.name : dom.array_name
32+
input(type: "hidden", name: hidden_name, value: "")
33+
end
34+
1135
if block_given?
12-
select(**attributes, &options)
36+
select(**attributes, &block)
1337
else
14-
select(**attributes) { options(*@collection) }
38+
select(**attributes) do
39+
options(*@options)
40+
end
1541
end
1642
end
1743

1844
def options(*collection)
45+
# Handle both single values and arrays (for multiple selects)
46+
selected_values = Array(field.value)
1947
map_options(collection).each do |key, value|
20-
option(selected: field.value == key, value: key) { value }
48+
if key.nil?
49+
blank_option
50+
else
51+
option(selected: selected_values.include?(key), value: key) { value }
52+
end
2153
end
2254
end
2355

@@ -37,6 +69,18 @@ def false_option(&)
3769
def map_options(collection)
3870
OptionMapper.new(collection)
3971
end
72+
73+
def field_attributes
74+
attrs = super
75+
if @multiple
76+
# Only append [] if the field doesn't already have a Field parent
77+
# (which would mean it's already in a collection and has [] notation)
78+
name = field.parent.is_a?(Superform::Field) ? attrs[:name] : dom.array_name
79+
attrs.merge(multiple: true, name: name)
80+
else
81+
attrs
82+
end
83+
end
4084
end
4185
end
4286
end

lib/superform/rails/field.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,14 @@ def textarea(**attributes)
4545
Components::Textarea.new(field, attributes:)
4646
end
4747

48-
def select(*collection, **attributes, &)
49-
Components::Select.new(field, attributes:, collection:, &)
48+
def select(*options, multiple: false, **attributes, &)
49+
Components::Select.new(
50+
field,
51+
attributes:,
52+
options:,
53+
multiple:,
54+
&
55+
)
5056
end
5157

5258
def errors

0 commit comments

Comments
 (0)