Skip to content

Commit 7425d56

Browse files
committed
Infer HTML5 validation attributes from ActiveRecord validators
Adds HTML5::ValidationAttributes class that reads ActiveRecord validators and produces corresponding HTML5 attributes (required, minlength, maxlength, min, max, step). A thin Validations module prepends onto Field to merge these attributes into input, checkbox, textarea, select, and radio calls. User kwargs always win over inferred attributes. Conditional validators (if:, unless:, on:) are skipped since they can't be evaluated at render time. Forms can opt out via `def novalidate = true`, which suppresses attribute injection and adds the novalidate attribute to the <form> tag.
1 parent 338222f commit 7425d56

9 files changed

Lines changed: 378 additions & 2 deletions

File tree

lib/superform.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ module Superform
44
Loader = Zeitwerk::Loader.for_gem.tap do |loader|
55
loader.ignore "#{__dir__}/generators"
66
loader.inflector.inflect(
7-
'dom' => 'DOM'
7+
'dom' => 'DOM',
8+
'html5' => 'HTML5'
89
)
910
loader.setup
1011
end

lib/superform/rails/field.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ module Rails
2525
#
2626
# Now all calls to `label` will have the `text-bold` class applied to it.
2727
class Field < Superform::Field
28+
prepend HTML5::Validations
29+
2830
def button(**attributes)
2931
Components::Button.new(field, **attributes)
3032
end

lib/superform/rails/form.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,14 @@ def around_template(&)
6464
end
6565
end
6666

67+
def novalidate
68+
false
69+
end
70+
6771
def form_tag(&)
68-
form action: form_action, method: form_method, **@attributes, &
72+
attrs = @attributes
73+
attrs = attrs.merge(novalidate: true) if novalidate
74+
form action: form_action, method: form_method, **attrs, &
6975
end
7076

7177
def view_template(&block)

lib/superform/rails/html5.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module Superform
2+
module Rails
3+
module HTML5
4+
end
5+
end
6+
end
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
module Superform
2+
module Rails
3+
module HTML5
4+
class ValidationAttributes
5+
attr_reader :object, :key
6+
7+
def initialize(object, key)
8+
@object = object
9+
@key = key
10+
end
11+
12+
def to_h
13+
return {} unless object.class.respond_to?(:validators_on)
14+
15+
attrs = {}
16+
validators = object.class.validators_on(key)
17+
18+
validators.each do |v|
19+
next if conditional?(v)
20+
21+
case v
22+
when ActiveRecord::Validations::PresenceValidator
23+
attrs[:required] = true
24+
when ActiveModel::Validations::LengthValidator
25+
merge_length!(attrs, v.options)
26+
when ActiveModel::Validations::NumericalityValidator
27+
merge_numericality!(attrs, v.options)
28+
end
29+
end
30+
31+
attrs
32+
end
33+
34+
private
35+
36+
def conditional?(validator)
37+
validator.options.key?(:if) ||
38+
validator.options.key?(:unless) ||
39+
validator.options.key?(:on)
40+
end
41+
42+
def merge_length!(attrs, opts)
43+
if opts[:is]
44+
attrs[:minlength] = opts[:is]
45+
attrs[:maxlength] = opts[:is]
46+
else
47+
attrs[:minlength] = opts[:minimum] || opts[:in]&.min if opts[:minimum] || opts[:in]
48+
attrs[:maxlength] = opts[:maximum] || opts[:in]&.max if opts[:maximum] || opts[:in]
49+
end
50+
end
51+
52+
def merge_numericality!(attrs, opts)
53+
attrs[:min] = opts[:greater_than_or_equal_to] if opts.key?(:greater_than_or_equal_to)
54+
attrs[:max] = opts[:less_than_or_equal_to] if opts.key?(:less_than_or_equal_to)
55+
attrs[:step] = 1 if opts[:only_integer]
56+
end
57+
end
58+
end
59+
end
60+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
module Superform
2+
module Rails
3+
module HTML5
4+
module Validations
5+
def validation_attributes
6+
form = find_form
7+
return {} if form&.respond_to?(:novalidate) && form.novalidate
8+
9+
ValidationAttributes.new(object, key).to_h
10+
end
11+
12+
def input(**attributes)
13+
super(**validation_attributes, **attributes)
14+
end
15+
16+
def checkbox(**attributes)
17+
super(**validation_attributes, **attributes)
18+
end
19+
20+
def textarea(**attributes)
21+
super(**validation_attributes, **attributes)
22+
end
23+
24+
def select(*options, **attributes, &block)
25+
super(*options, **validation_attributes, **attributes, &block)
26+
end
27+
28+
def radio(value, **attributes)
29+
super(value, **validation_attributes, **attributes)
30+
end
31+
32+
private
33+
34+
def find_form
35+
node = parent
36+
while node
37+
return node.form if node.respond_to?(:form)
38+
node = node.respond_to?(:parent) ? node.parent : nil
39+
end
40+
nil
41+
end
42+
end
43+
end
44+
end
45+
end
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
RSpec.describe Superform::Rails::HTML5::ValidationAttributes do
2+
subject(:validation_attributes) { described_class.new(object, key) }
3+
4+
describe "#to_h" do
5+
context "presence" do
6+
let(:object) { User.new }
7+
8+
context "with presence validator" do
9+
let(:key) { :first_name }
10+
11+
it "returns required: true" do
12+
expect(validation_attributes.to_h).to include(required: true)
13+
end
14+
end
15+
16+
context "without presence validator" do
17+
let(:key) { :last_name }
18+
19+
it "returns empty hash" do
20+
expect(validation_attributes.to_h).to eq({})
21+
end
22+
end
23+
end
24+
25+
context "length" do
26+
let(:object) { Product.new }
27+
28+
it "returns minlength and maxlength for minimum/maximum" do
29+
expect(described_class.new(object, :name).to_h).to include(minlength: 2, maxlength: 100)
30+
end
31+
32+
it "returns minlength and maxlength for in: range" do
33+
expect(described_class.new(object, :description).to_h).to eq({ minlength: 10, maxlength: 500 })
34+
end
35+
end
36+
37+
context "numericality" do
38+
let(:object) { Product.new }
39+
40+
it "returns min, max, and step for integer field with bounds" do
41+
expect(described_class.new(object, :quantity).to_h).to include(min: 0, max: 1000, step: 1)
42+
end
43+
44+
it "returns min for field with only greater_than_or_equal_to" do
45+
expect(described_class.new(object, :price).to_h).to eq({ min: 0 })
46+
end
47+
end
48+
49+
context "combined validators" do
50+
let(:object) { Product.new }
51+
52+
it "merges all validation attributes for a field" do
53+
expect(described_class.new(object, :name).to_h).to eq({ required: true, minlength: 2, maxlength: 100 })
54+
end
55+
end
56+
57+
context "conditional validators" do
58+
let(:object) { ConditionalUser.new }
59+
60+
it "skips validators with if: option" do
61+
expect(described_class.new(object, :username).to_h).to eq({})
62+
end
63+
64+
it "skips validators with on: option" do
65+
expect(described_class.new(object, :email).to_h).to eq({})
66+
end
67+
end
68+
69+
context "object without validators" do
70+
let(:object) { double("plain object") }
71+
let(:key) { :anything }
72+
73+
it "returns empty hash" do
74+
expect(validation_attributes.to_h).to eq({})
75+
end
76+
end
77+
end
78+
end
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
RSpec.describe Superform::Rails::HTML5::Validations, type: :view do
2+
describe "#validation_attributes" do
3+
let(:form) { Superform::Rails::Form.new(model, action: "/test") }
4+
5+
context "with combined validators" do
6+
let(:model) { Product.new }
7+
8+
it "merges all validation attributes" do
9+
field = form.field(:name)
10+
attrs = field.validation_attributes
11+
expect(attrs).to eq({ required: true, minlength: 2, maxlength: 100 })
12+
end
13+
14+
it "returns only relevant attributes per field" do
15+
field = form.field(:quantity)
16+
attrs = field.validation_attributes
17+
expect(attrs).to eq({ min: 0, max: 1000, step: 1 })
18+
end
19+
end
20+
21+
context "with conditional validators" do
22+
let(:model) { ConditionalUser.new }
23+
24+
it "returns empty hash when all validators are conditional" do
25+
field = form.field(:username)
26+
attrs = field.validation_attributes
27+
expect(attrs).to eq({})
28+
end
29+
end
30+
end
31+
32+
describe "Field method overrides" do
33+
let(:model) { User.new }
34+
let(:form) { Superform::Rails::Form.new(model, action: "/users") }
35+
36+
describe "#input" do
37+
it "injects required attribute for presence-validated field" do
38+
html = render(form) { |f| f.render f.field(:first_name).input }
39+
expect(html).to include('required')
40+
expect(html).to include('name="user[first_name]"')
41+
end
42+
43+
it "does not inject required for non-validated field" do
44+
html = render(form) { |f| f.render f.field(:last_name).input }
45+
expect(html).not_to include('required')
46+
end
47+
48+
it "allows user kwargs to override validation attributes" do
49+
html = render(form) { |f| f.render f.field(:first_name).input(required: false) }
50+
expect(html).not_to include('required')
51+
end
52+
end
53+
54+
describe "#textarea" do
55+
it "injects required attribute for presence-validated field" do
56+
html = render(form) { |f| f.render f.field(:first_name).textarea }
57+
expect(html).to include('required')
58+
expect(html).to include('<textarea')
59+
end
60+
end
61+
62+
describe "#checkbox" do
63+
it "injects required attribute for presence-validated field" do
64+
html = render(form) { |f| f.render f.field(:first_name).checkbox }
65+
expect(html).to include('required')
66+
end
67+
end
68+
69+
describe "#select" do
70+
it "injects required attribute for presence-validated field" do
71+
html = render(form) { |f| f.render f.field(:first_name).select(["A", "B"]) }
72+
expect(html).to include('required')
73+
expect(html).to include('<select')
74+
end
75+
end
76+
77+
describe "#radio" do
78+
it "injects required attribute for presence-validated field" do
79+
html = render(form) { |f| f.render f.field(:first_name).radio("male") }
80+
expect(html).to include('required')
81+
expect(html).to include('type="radio"')
82+
end
83+
end
84+
85+
describe "convenience methods" do
86+
let(:model) { Product.new }
87+
let(:form) { Superform::Rails::Form.new(model, action: "/products") }
88+
89+
it "injects validation attributes through #number" do
90+
html = render(form) { |f| f.render f.field(:quantity).number }
91+
expect(html).to include('type="number"')
92+
expect(html).to include('min="0"')
93+
expect(html).to include('max="1000"')
94+
expect(html).to include('step="1"')
95+
end
96+
97+
it "injects validation attributes through #text" do
98+
html = render(form) { |f| f.render f.field(:name).text }
99+
expect(html).to include('type="text"')
100+
expect(html).to include('required')
101+
expect(html).to include('minlength="2"')
102+
expect(html).to include('maxlength="100"')
103+
end
104+
end
105+
end
106+
107+
describe "novalidate" do
108+
let(:model) { User.new }
109+
110+
context "when novalidate is false (default)" do
111+
let(:form) { Superform::Rails::Form.new(model, action: "/users") }
112+
113+
it "does not add novalidate to form tag" do
114+
html = render(form)
115+
expect(html).not_to include('novalidate')
116+
end
117+
118+
it "injects validation attributes" do
119+
html = render(form) { |f| f.render f.field(:first_name).input }
120+
expect(html).to include('required')
121+
end
122+
end
123+
124+
context "when novalidate is true" do
125+
let(:novalidate_form_class) do
126+
Class.new(Superform::Rails::Form) do
127+
def novalidate = true
128+
end
129+
end
130+
let(:form) { novalidate_form_class.new(model, action: "/users") }
131+
132+
it "adds novalidate to form tag" do
133+
html = render(form)
134+
expect(html).to include('novalidate')
135+
end
136+
137+
it "does not inject validation attributes" do
138+
html = render(form) { |f| f.render f.field(:first_name).input }
139+
expect(html).not_to match(/required(?!.*novalidate)/)
140+
# The form tag itself has novalidate, but the input should not have required
141+
input_tag = html.match(/<input[^>]*name="user\[first_name\]"[^>]*>/)[0]
142+
expect(input_tag).not_to include('required')
143+
end
144+
end
145+
end
146+
end

0 commit comments

Comments
 (0)