Skip to content

About

Universal Form Objects based on dry-validation with input helpers similar to simple_form

Resources

Stars

5 stars

Watchers

2 watching

Forks

Repository files navigation

ActiveDryForm

Installation

Add this line to your application's Gemfile:

gem 'active_dry_form'

Under the hood ActiveDryForm uses dry-validation, dry-monads

Initialize form

ProductForm.new(params: { title: 'n', price: '120' })
ProductForm.new(params: { product: { title: 'n', price: '120' } })
ProductForm.new(record: Product.find(params[:id]), params:)

Attribute accessors

form.title # => 'n'
form.price # => '120'
form.price = '119' # => '119'
form[:price] # => '119'
form[:price] = '121' # => '121'

Methods

form.validate # => checks field validity
form.validator # => #<Dry::Validation::Result{:title=>"n", :price=>120, errors={:title=>["minimum length 2"]}...>
form.valid? # => false
form.persisted? # => true
form.errors # => {:title=>["minimum length 2"]}
form.base_errors = []
form.errors_full_messages # => ['Cannot be less than 2 words']
form.record # => #<Product:0x00007f05c27106c8 id: 1, title: 'name', price: 100, description: 'product'>
# form.data - is hash, casted to types, after validate
form.data # => {:title=>"n", :price=>120}
form.data[:price] # => 120
form.update # Failure(:invalid_form)

Methods form.update and form.create return Result monad. Actions must explicitly return Success or Failure; any other return value raises ActiveDryForm::Form::ResultError.

# app/forms/product_form.rb

class ProductForm < Form
  fields :product do
    params do
      required(:title).filled(:string, min_size?: 2)
      required(:price).filled(:integer)
      optional(:description).maybe(:string)
      optional(:upload_attachments).maybe(:array)
    end

    rule(:description) do
      key.failure('Cannot be less than 2 words') if value.split.size < 2
    end
  end

  action def update
    # Here you can implement any save logic

    record.update!(data)
    Success(record)
  end

end

Actions also handle Dry::Monads::UnwrapError raised by value! (including service methods such as call!). The action returns the original Failure and sets form.base_errors to Array.wrap(result.failure).map(&:to_s). Other exceptions propagate normally.

class CaptchaValidateService
  extend Dry::Monads[:result]

  def self.call(token:, ip:, hostname:)
    return Failure('Captcha token is missing') if token.blank?

    verified = CaptchaClient.verify(token:, ip:, hostname:)
    return Failure('Invalid captcha') unless verified

    Success()
  end

  def self.call!(**params)
    # value! unwraps Success and returns its value.
    # For Failure, it raises Dry::Monads::UnwrapError with the original monad in receiver.
    call(**params).value!
  end
end

class UserForm < Form
  fields :user do
    params do
      required(:email).filled(:string)
    end
  end

  action def create(token:, ip:, hostname:)
    # If verification returns Failure('Invalid captcha'), call! raises Dry::Monads::UnwrapError.
    # The action wrapper catches it, returns the original Failure, and sets
    # form.base_errors to ['Invalid captcha'], skipping user creation.
    # Other exceptions, including those from CaptchaClient.verify, propagate to the caller.
    CaptchaValidateService.call!(token:, ip:, hostname:)
    Success(User.create!(data))
  end
end

In your controller

class ProductsController < ApplicationController
  include Dry::Monads[:result]

  def new
    @form = ProductForm.new
  end

  def create # without monads
    @form = ProductForm.new(params:)
    @form.validate

    if @form.valid?
      Product.create!(@form)

      redirect_to products_path
    else
      render :new
    end
  end

  def edit
    @form = ProductForm.new(record:)
  end

  def update # with monads
    @form = ProductForm.new(record:, params:)

    case @form.update
    in Success(product)
      redirect_to product
    else
      render :edit
    end
  end

  private def record = Product.find(params[:id])
end

in your view (slim for example)

/ app/views/products/new.slim

- active_dry_form_for @form do |f|
  = f.input :title
  = f.input :price
  = f.input_select :status, Product::STATUSES.values
  = f.input_file :upload_attachments, multiple: true, label: false
  = f.button 'Submit'

Fill attributes init values

In your controller

def new
  @form = ProductForm.new(params: { title: 'name', price: 120 })
  @form.attributes = { title: 'new name', price: 100 }
  @form.description = 'product description'
end

or like this

def new
  @form = ProductForm.new
  @form.create_default(params[:title])

  # class ProductForm < Form
  #   ...
  #   def create_default(title)
  #     self.title = title
  #   end
  # end
end

Inputs

Inputs under the hood uses standard rails builder form methods, and you can use it on form.

input method automatically determines tag type:

  • by dry type predicates - date, time, integer, number, string, boolean
  • by field name - password, email, telephone, url
- active_dry_form_for @form, html: { 'data-controller': 'product'} do |f|
  = f.input :title, 'data-product-target': 'title', readonly: true,
  = f.show_error(:title)
  = f.input_select :category_id, Category.pluck(:name, :id),
    { include_blank: true },
    { label: false, multiple: true, style: 'max-width:unset;'}
  = f.input_check_box :is_discount
  = f.input_check_box_inline :is_sale
  = f.input_text :shipper_name
  = f.input_text_area :description
  = f.input_hidden :admin_id
  = f.input_file :upload_attachments, multiple: true, label: false
  = f.input_date :date
  = f.input_datetime :date_time
  = f.input_integer :category_id
  = f.input_number :number
  = f.input_password :password
  = f.input_email :email
  = f.input_url :url
  = f.input_telephone :telephone

  // standard rails builder form methods
  = f.label :name
  = f.search_field :name

  = f.button 'Submit'

Create custom input

# lib/acitve_dry_form/builder.rb

module ActiveDryForm
  class Builder

    def input_date_native(field, options = {})
      wrap_input(__method__, field, options) { |opts| date_field(field, opts) }
    end

  end
end

The form can be nested

class NestedDryForm < Form

  class BookmarkForm < Form
    fields(:bookmark) do
      params do
        required(:url).filled(:string)
        optional(:id).maybe(:integer)
        optional(:name).maybe(:string)
      end
    end
  end

  fields :product do
    params do
      required(:title).filled(:string, min_size?: 2)
      required(:price).filled(:integer)
      optional(:description).maybe(:string)
      optional(:upload_attachments).maybe(:array)
      optional(:bookmarks).array(Dry.Types.Constructor(BookmarkForm) { |params| BookmarkForm.new(params: params) })
    end
  end

  action def update
    bookmarks_data = data.delete(:bookmarks)

    record.attributes = data
    record.bookmarks_attributes = bookmarks_data if bookmarks_data
    record.save!

    Success(record)
  end

end

As you noticed in the above example, we use the construction Dry.Types.Constructor(BookmarkForm) { |params| BookmarkForm.new(params: params) }, what it is dry types you can find out here

Internationalization

By default, uses i18n_key from fields(:user), but you can set a custom key using fields(:profile, i18n_key: :user).

Using standard rails i18n path:

helpers.label

ru:
  helpers:
    label:
      user:
        id: Идентификатор
        name: Имя

or translations for your model attribute names

ru:
  activerecord:
    attributes:
      user:
        id: Идентификатор
        name: Имя

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.


Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/active_dry_form.


License

The gem is available as open source under the terms of the MIT License.

About

Universal Form Objects based on dry-validation with input helpers similar to simple_form

Resources

Stars

5 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages