Skip to content
Draft
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
3 changes: 2 additions & 1 deletion lib/superform.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ module Superform
Loader = Zeitwerk::Loader.for_gem.tap do |loader|
loader.ignore "#{__dir__}/generators"
loader.inflector.inflect(
'dom' => 'DOM'
'dom' => 'DOM',
'html5' => 'HTML5'
)
loader.setup
end
Expand Down
2 changes: 2 additions & 0 deletions lib/superform/rails/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ module Rails
#
# Now all calls to `label` will have the `text-bold` class applied to it.
class Field < Superform::Field
prepend HTML5::Validations

def button(**attributes)
Components::Button.new(field, **attributes)
end
Expand Down
8 changes: 7 additions & 1 deletion lib/superform/rails/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,14 @@ def around_template(&)
end
end

def novalidate
false
end

def form_tag(&)
form action: form_action, method: form_method, **@attributes, &
attrs = @attributes
attrs = attrs.merge(novalidate: true) if novalidate
form action: form_action, method: form_method, **attrs, &
end

def view_template(&block)
Expand Down
6 changes: 6 additions & 0 deletions lib/superform/rails/html5.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module Superform
module Rails
module HTML5
end
end
end
60 changes: 60 additions & 0 deletions lib/superform/rails/html5/validation_attributes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
module Superform
module Rails
module HTML5
class ValidationAttributes
attr_reader :object, :key

def initialize(object, key)
@object = object
@key = key
end

def to_h
return {} unless object.class.respond_to?(:validators_on)

attrs = {}
validators = object.class.validators_on(key)

validators.each do |v|
next if conditional?(v)

case v
when ActiveRecord::Validations::PresenceValidator
attrs[:required] = true
when ActiveModel::Validations::LengthValidator
merge_length!(attrs, v.options)
when ActiveModel::Validations::NumericalityValidator
merge_numericality!(attrs, v.options)
end
end

attrs
end

private

def conditional?(validator)
validator.options.key?(:if) ||
validator.options.key?(:unless) ||
validator.options.key?(:on)
end

def merge_length!(attrs, opts)
if opts[:is]
attrs[:minlength] = opts[:is]
attrs[:maxlength] = opts[:is]
else
attrs[:minlength] = opts[:minimum] || opts[:in]&.min if opts[:minimum] || opts[:in]
attrs[:maxlength] = opts[:maximum] || opts[:in]&.max if opts[:maximum] || opts[:in]
end
end

def merge_numericality!(attrs, opts)
attrs[:min] = opts[:greater_than_or_equal_to] if opts.key?(:greater_than_or_equal_to)
attrs[:max] = opts[:less_than_or_equal_to] if opts.key?(:less_than_or_equal_to)
attrs[:step] = 1 if opts[:only_integer]
end
end
end
end
end
45 changes: 45 additions & 0 deletions lib/superform/rails/html5/validations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Superform
module Rails
module HTML5
module Validations
def validation_attributes
form = find_form
return {} if form&.respond_to?(:novalidate) && form.novalidate

ValidationAttributes.new(object, key).to_h
end

def input(**attributes)
super(**validation_attributes, **attributes)
end

def checkbox(**attributes)
super(**validation_attributes, **attributes)
end

def textarea(**attributes)
super(**validation_attributes, **attributes)
end

def select(*options, **attributes, &block)
super(*options, **validation_attributes, **attributes, &block)
end

def radio(value, **attributes)
super(value, **validation_attributes, **attributes)
end

private

def find_form
node = parent
while node
return node.form if node.respond_to?(:form)
node = node.respond_to?(:parent) ? node.parent : nil
end
nil
end
end
end
end
end
78 changes: 78 additions & 0 deletions spec/superform/rails/html5/validation_attributes_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
RSpec.describe Superform::Rails::HTML5::ValidationAttributes do
subject(:validation_attributes) { described_class.new(object, key) }

describe "#to_h" do
context "presence" do
let(:object) { User.new }

context "with presence validator" do
let(:key) { :first_name }

it "returns required: true" do
expect(validation_attributes.to_h).to include(required: true)
end
end

context "without presence validator" do
let(:key) { :last_name }

it "returns empty hash" do
expect(validation_attributes.to_h).to eq({})
end
end
end

context "length" do
let(:object) { Product.new }

it "returns minlength and maxlength for minimum/maximum" do
expect(described_class.new(object, :name).to_h).to include(minlength: 2, maxlength: 100)
end

it "returns minlength and maxlength for in: range" do
expect(described_class.new(object, :description).to_h).to eq({ minlength: 10, maxlength: 500 })
end
end

context "numericality" do
let(:object) { Product.new }

it "returns min, max, and step for integer field with bounds" do
expect(described_class.new(object, :quantity).to_h).to include(min: 0, max: 1000, step: 1)
end

it "returns min for field with only greater_than_or_equal_to" do
expect(described_class.new(object, :price).to_h).to eq({ min: 0 })
end
end

context "combined validators" do
let(:object) { Product.new }

it "merges all validation attributes for a field" do
expect(described_class.new(object, :name).to_h).to eq({ required: true, minlength: 2, maxlength: 100 })
end
end

context "conditional validators" do
let(:object) { ConditionalUser.new }

it "skips validators with if: option" do
expect(described_class.new(object, :username).to_h).to eq({})
end

it "skips validators with on: option" do
expect(described_class.new(object, :email).to_h).to eq({})
end
end

context "object without validators" do
let(:object) { double("plain object") }
let(:key) { :anything }

it "returns empty hash" do
expect(validation_attributes.to_h).to eq({})
end
end
end
end
146 changes: 146 additions & 0 deletions spec/superform/rails/html5/validations_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
RSpec.describe Superform::Rails::HTML5::Validations, type: :view do
describe "#validation_attributes" do
let(:form) { Superform::Rails::Form.new(model, action: "/test") }

context "with combined validators" do
let(:model) { Product.new }

it "merges all validation attributes" do
field = form.field(:name)
attrs = field.validation_attributes
expect(attrs).to eq({ required: true, minlength: 2, maxlength: 100 })
end

it "returns only relevant attributes per field" do
field = form.field(:quantity)
attrs = field.validation_attributes
expect(attrs).to eq({ min: 0, max: 1000, step: 1 })
end
end

context "with conditional validators" do
let(:model) { ConditionalUser.new }

it "returns empty hash when all validators are conditional" do
field = form.field(:username)
attrs = field.validation_attributes
expect(attrs).to eq({})
end
end
end

describe "Field method overrides" do
let(:model) { User.new }
let(:form) { Superform::Rails::Form.new(model, action: "/users") }

describe "#input" do
it "injects required attribute for presence-validated field" do
html = render(form) { |f| f.render f.field(:first_name).input }
expect(html).to include('required')
expect(html).to include('name="user[first_name]"')
end

it "does not inject required for non-validated field" do
html = render(form) { |f| f.render f.field(:last_name).input }
expect(html).not_to include('required')
end

it "allows user kwargs to override validation attributes" do
html = render(form) { |f| f.render f.field(:first_name).input(required: false) }
expect(html).not_to include('required')
end
end

describe "#textarea" do
it "injects required attribute for presence-validated field" do
html = render(form) { |f| f.render f.field(:first_name).textarea }
expect(html).to include('required')
expect(html).to include('<textarea')
end
end

describe "#checkbox" do
it "injects required attribute for presence-validated field" do
html = render(form) { |f| f.render f.field(:first_name).checkbox }
expect(html).to include('required')
end
end

describe "#select" do
it "injects required attribute for presence-validated field" do
html = render(form) { |f| f.render f.field(:first_name).select(["A", "B"]) }
expect(html).to include('required')
expect(html).to include('<select')
end
end

describe "#radio" do
it "injects required attribute for presence-validated field" do
html = render(form) { |f| f.render f.field(:first_name).radio("male") }
expect(html).to include('required')
expect(html).to include('type="radio"')
end
end

describe "convenience methods" do
let(:model) { Product.new }
let(:form) { Superform::Rails::Form.new(model, action: "/products") }

it "injects validation attributes through #number" do
html = render(form) { |f| f.render f.field(:quantity).number }
expect(html).to include('type="number"')
expect(html).to include('min="0"')
expect(html).to include('max="1000"')
expect(html).to include('step="1"')
end

it "injects validation attributes through #text" do
html = render(form) { |f| f.render f.field(:name).text }
expect(html).to include('type="text"')
expect(html).to include('required')
expect(html).to include('minlength="2"')
expect(html).to include('maxlength="100"')
end
end
end

describe "novalidate" do
let(:model) { User.new }

context "when novalidate is false (default)" do
let(:form) { Superform::Rails::Form.new(model, action: "/users") }

it "does not add novalidate to form tag" do
html = render(form)
expect(html).not_to include('novalidate')
end

it "injects validation attributes" do
html = render(form) { |f| f.render f.field(:first_name).input }
expect(html).to include('required')
end
end

context "when novalidate is true" do
let(:novalidate_form_class) do
Class.new(Superform::Rails::Form) do
def novalidate = true
end
end
let(:form) { novalidate_form_class.new(model, action: "/users") }

it "adds novalidate to form tag" do
html = render(form)
expect(html).to include('novalidate')
end

it "does not inject validation attributes" do
html = render(form) { |f| f.render f.field(:first_name).input }
expect(html).not_to match(/required(?!.*novalidate)/)
# The form tag itself has novalidate, but the input should not have required
input_tag = html.match(/<input[^>]*name="user\[first_name\]"[^>]*>/)[0]
expect(input_tag).not_to include('required')
end
end
end
end
Loading