├── lib └── reform │ ├── rails │ ├── version.rb │ └── railtie.rb │ ├── rails.rb │ ├── mongoid.rb │ ├── active_record.rb │ └── form │ ├── mongoid.rb │ ├── active_model │ ├── result.rb │ ├── acceptance_validator_patch.rb │ ├── form_builder_methods.rb │ ├── model_reflections.rb │ ├── model_validations.rb │ └── validations.rb │ ├── active_record.rb │ ├── orm.rb │ ├── multi_parameter_attributes.rb │ ├── active_model.rb │ └── validation │ └── unique_validator.rb ├── Gemfile-rails6.0 ├── Gemfile-rails7.1 ├── Gemfile-rails7.2 ├── Gemfile-rails8 ├── .gitignore ├── Gemfile-rails6.1 ├── Gemfile-rails7.0 ├── Gemfile-rails5 ├── Rakefile ├── test ├── active_model_validation_for_property_named_format_test.rb ├── support │ └── schema.rb ├── form_test.rb ├── custom_validation_test.rb ├── parent_test.rb ├── fixtures │ └── locales │ │ └── en.yml ├── test_helper.rb ├── multi_parameter_attributes_test.rb ├── model_validations_test.rb ├── active_model_custom_validation_translations_test.rb ├── model_reflections_test.rb ├── form_builder_test.rb ├── active_model_test.rb ├── unique_test.rb ├── active_record_test.rb ├── mongoid_test.rb └── activemodel_validation_test.rb ├── LICENSE.txt ├── reform-rails.gemspec ├── README.md ├── CHANGES.md └── .github └── workflows └── ci.yml /lib/reform/rails/version.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | module Rails 3 | VERSION = "0.3.1" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/reform/rails.rb: -------------------------------------------------------------------------------- 1 | require "reform/rails/version" 2 | require 'reform' 3 | require "reform/rails/railtie" 4 | 5 | module Reform 6 | end 7 | -------------------------------------------------------------------------------- /Gemfile-rails6.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | ruby '~> 2.6' 5 | 6 | gem "activerecord", "~> 6.0.0" 7 | gem "railties", "~> 6.0.0" 8 | gem "sqlite3" 9 | gem "mongoid" 10 | -------------------------------------------------------------------------------- /Gemfile-rails7.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | ruby '~> 3.2' 5 | 6 | gem "activerecord", "~> 7.1.0" 7 | gem "railties", "~> 7.1.0" 8 | gem "sqlite3" 9 | gem "mongoid" 10 | -------------------------------------------------------------------------------- /Gemfile-rails7.2: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | ruby '~> 3.3' 5 | 6 | gem "activerecord", "~> 7.2.0" 7 | gem "railties", "~> 7.2.0" 8 | gem "sqlite3" 9 | gem "mongoid" 10 | -------------------------------------------------------------------------------- /Gemfile-rails8: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | ruby '~> 3.2' 5 | 6 | gem "activerecord", "~> 8.0.0" 7 | gem "railties", "~> 8.0.0" 8 | gem "sqlite3" 9 | gem "mongoid" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /*.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.gem 11 | *.sqlite3 12 | .byebug* 13 | *.log 14 | .rubocop-https* 15 | -------------------------------------------------------------------------------- /Gemfile-rails6.1: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | ruby '~> 3.0' 5 | 6 | gem "activerecord", "~> 6.1.0" 7 | gem "railties", "~> 6.1.0" 8 | gem "sqlite3", "~> 1.4" 9 | gem "mongoid" 10 | -------------------------------------------------------------------------------- /Gemfile-rails7.0: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | ruby '~> 3.0' 5 | 6 | gem "activerecord", "~> 7.0.0" 7 | gem "railties", "~> 7.0.0" 8 | gem "sqlite3", "~> 1.4" 9 | gem "mongoid" 10 | -------------------------------------------------------------------------------- /Gemfile-rails5: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | ruby '~> 2.5' 5 | 6 | gem "mongoid", "< 7.0" 7 | gem "activerecord", "~> 5.2" 8 | gem "railties", "~> 5.2" 9 | gem "sqlite3", "~> 1.3", "< 1.4" 10 | -------------------------------------------------------------------------------- /lib/reform/mongoid.rb: -------------------------------------------------------------------------------- 1 | require 'reform/form/orm' 2 | require 'reform/form/active_model' 3 | require 'reform/form/mongoid' 4 | require 'reform/form/active_model/model_reflections' # only load this in AR context as simple_form currently is bound to AR. 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList['test/*_test.rb'] 8 | t.warning = false 9 | t.verbose = true 10 | end 11 | 12 | task :default => :test 13 | -------------------------------------------------------------------------------- /lib/reform/active_record.rb: -------------------------------------------------------------------------------- 1 | require "reform/form/orm" 2 | require "reform/form/active_model" 3 | require "reform/form/active_record" 4 | require "reform/form/active_model/model_reflections" # only load this in AR context as simple_form currently is bound to AR. 5 | require "reform/form/active_model/result" 6 | -------------------------------------------------------------------------------- /test/active_model_validation_for_property_named_format_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class AMValidationWithFormatTest < Minitest::Spec 4 | class SongForm < Reform::Form 5 | include Reform::Form::ActiveModel 6 | 7 | property :format 8 | validates :format, presence: true 9 | end 10 | 11 | Song = Struct.new(:format) 12 | 13 | it do 14 | _(SongForm.new(Song.new).validate({ format: 12 })).must_equal true 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(version: 1) do 2 | create_table "songs" do |t| 3 | t.string "title" 4 | t.date "release_date" 5 | t.integer "artist_id" 6 | t.integer "album_id" 7 | t.datetime "created_at" 8 | t.datetime "updated_at" 9 | t.datetime "archived_at" 10 | end 11 | 12 | create_table "artists" do |t| 13 | t.string "name" 14 | t.datetime "created_at" 15 | t.datetime "updated_at" 16 | end 17 | 18 | create_table "albums" do |t| 19 | t.string "title" 20 | t.datetime "created_at" 21 | t.datetime "updated_at" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/reform/form/mongoid.rb: -------------------------------------------------------------------------------- 1 | gem 'mongoid', ">= 4.0" 2 | 3 | module Reform::Form::Mongoid 4 | def self.included(base) 5 | base.class_eval do 6 | register_feature Reform::Form::Mongoid 7 | include Reform::Form::ActiveModel 8 | include Reform::Form::ORM 9 | extend ClassMethods 10 | end 11 | end 12 | 13 | module ClassMethods 14 | def validates_uniqueness_of(attribute, options={}) 15 | options = options.merge(:attributes => [attribute]) 16 | validates_with(UniquenessValidator, options) 17 | end 18 | def i18n_scope 19 | :mongoid 20 | end 21 | end 22 | 23 | UniquenessValidator = Class.new("::Mongoid::Validatable::UniquenessValidator".constantize) do 24 | include Reform::Form::ORM::UniquenessValidator 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/reform/form/active_model/result.rb: -------------------------------------------------------------------------------- 1 | class Reform::Contract::Result 2 | private 3 | 4 | def filter_for(method, *args) 5 | @results.collect { |r| r.public_send(method, *args).to_h } 6 | .inject({}) { |hah, err| hah.merge(err) { |key, old_v, new_v| (new_v.is_a?(Array) ? (old_v |= new_v) : old_v.merge(new_v)) } } 7 | .find_all { |k, v| # filter :nested=>{:something=>["too nested!"]} #DISCUSS: do we want that here? 8 | if v.is_a?(Hash) 9 | nested_errors = v.select { |attr_key, val| attr_key.is_a?(Integer) && val.is_a?(Array) && val.any? } 10 | v = nested_errors.to_a if nested_errors.any? 11 | end 12 | v.is_a?(Array) || v.class.to_s == "ActiveModel::DeprecationHandlingMessageArray" 13 | }.to_h 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/reform/form/active_record.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::ActiveRecord 2 | def self.included(base) 3 | base.class_eval do 4 | register_feature Reform::Form::ActiveRecord 5 | include Reform::Form::ActiveModel 6 | include Reform::Form::ORM 7 | extend ClassMethods 8 | end 9 | end 10 | 11 | module ClassMethods 12 | def validates_uniqueness_of(attribute, options={}) 13 | options = options.merge(:attributes => [attribute]) 14 | validates_with(UniquenessValidator, options) 15 | end 16 | def i18n_scope 17 | :activerecord 18 | end 19 | end 20 | 21 | def to_nested_hash(*) 22 | super.with_indifferent_access 23 | end 24 | 25 | class UniquenessValidator < ::ActiveRecord::Validations::UniquenessValidator 26 | include Reform::Form::ORM::UniquenessValidator 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2021 Nick Sutterer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/form_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FormTest < Minitest::Spec 4 | # combined property/validates syntax. 5 | class SongForm < Reform::Form 6 | property :composer 7 | property :title, validates: {presence: true} 8 | properties :genre, :band, validates: {presence: true} 9 | end 10 | it do 11 | form = SongForm.new(OpenStruct.new) 12 | form.validate({}) 13 | _(form.errors.messages).must_equal({:title=>["can't be blank"], :genre=>["can't be blank"], :band=>["can't be blank"]}) 14 | end 15 | 16 | Album = Struct.new(:hit) 17 | Song = Struct.new(:length) 18 | class PopulatedAlbumForm < Reform::Form 19 | property :hit, populate_if_empty: Song do 20 | property :length 21 | validates :length, numericality: { greater_than: 55 } 22 | end 23 | end 24 | it do 25 | form = PopulatedAlbumForm.new(Album.new) 26 | _(form.validate({ :hit => { :length => "54" }})).must_equal(false) 27 | _(form.errors.messages).must_equal({ :"hit.length" => ["must be greater than 55"] }) 28 | _(form.validate({ :hit => { :length => "57" }})).must_equal(true) 29 | _(form.errors.messages).must_equal({}) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/custom_validation_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UnexistantTitleValidator < ActiveModel::Validator 4 | def validate record 5 | if record.title == 'unexistant_song' 6 | record.errors.add(:title, 'this title does not exist!') 7 | end 8 | end 9 | end 10 | 11 | class CustomValidationTest < Minitest::Spec 12 | 13 | class Album 14 | include ActiveModel::Validations 15 | attr_accessor :title, :artist 16 | 17 | validates_with UnexistantTitleValidator 18 | end 19 | 20 | class AlbumForm < Reform::Form 21 | extend ActiveModel::ModelValidations 22 | 23 | property :title 24 | property :artist_name, from: :artist 25 | copy_validations_from Album 26 | end 27 | 28 | let(:album) { Album.new } 29 | 30 | describe 'non-composite form' do 31 | 32 | let(:album_form) { AlbumForm.new(album) } 33 | 34 | it 'is not valid when title is unexistant_song' do 35 | _(album_form.validate(artist_name: 'test', title: 'unexistant_song')).must_equal false 36 | end 37 | 38 | it 'is valid when title is something existant' do 39 | _(album_form.validate(artist_name: 'test', title: 'test')).must_equal true 40 | end 41 | 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /test/parent_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'disposable/twin/parent' 3 | 4 | class ParentTest < BaseTest 5 | 6 | class AlbumForm < Reform::Form 7 | 8 | feature Disposable::Twin::Parent 9 | 10 | property :band 11 | validates :band, presence: true 12 | 13 | collection :songs, virtual: true, default: [], populator: :populate_songs! do 14 | property :name 15 | validate :unique_name 16 | 17 | def unique_name 18 | if name == parent.band 19 | errors.add(:name, "Song name shouldn't be the same as #{parent.band}") 20 | end 21 | end 22 | end 23 | 24 | def populate_songs!(fragment:, **) 25 | existed_song = songs.find { |song| song.name == fragment[:name] } 26 | return existed_song if existed_song 27 | songs.append(OpenStruct.new(name: fragment[:name])) 28 | end 29 | 30 | end 31 | 32 | let (:form) { 33 | AlbumForm.new(OpenStruct.new( 34 | :band => "Killer Queen" 35 | )) 36 | } 37 | 38 | it "allows nested collection validation messages to be shown" do 39 | form.validate(songs: [{ name: "Killer Queen" }]) 40 | _(form.errors.full_messages).must_equal(["Name Song name shouldn't be the same as Killer Queen"]) 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /reform-rails.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'reform/rails/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "reform-rails" 8 | spec.version = Reform::Rails::VERSION 9 | spec.authors = ["Nick Sutterer"] 10 | spec.email = ["apotonick@gmail.com"] 11 | 12 | spec.summary = %q{Automatically load and include all common Rails form features.} 13 | spec.description = %q{Automatically load and include all common Reform features for a standard Rails environment.} 14 | spec.homepage = "https://github.com/trailblazer/reform-rails" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 18 | f.match(%r{^(test/|spec/|features/|database.sqlite3)}) 19 | end 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ["lib"] 22 | 23 | spec.add_dependency "reform", ">= 2.3.1", "< 3.0.0" 24 | spec.add_dependency "activemodel", ">= 5.0" 25 | spec.add_development_dependency "minitest" 26 | spec.add_development_dependency "minitest-line" 27 | spec.add_development_dependency "debug" 28 | end 29 | -------------------------------------------------------------------------------- /lib/reform/form/active_model/acceptance_validator_patch.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::ActiveModel 2 | module AcceptanceValidatorPatch 3 | def self.apply! 4 | return if defined?(::ActiveModel::Validations::ReformAcceptanceValidator) 5 | 6 | klass = Class.new(::ActiveModel::EachValidator) do 7 | def initialize(options) 8 | super({ allow_nil: true, accept: ["1", true] }.merge!(options)) 9 | end 10 | 11 | def validate_each(record, attribute, value) 12 | unless acceptable_option?(value) 13 | if Gem::Version.new(::ActiveModel::VERSION::STRING) >= Gem::Version.new('6.1.0') 14 | record.errors.add(attribute, :accepted, **options.except(:accept, :allow_nil)) 15 | else 16 | record.errors.add(attribute, :accepted, options.except(:accept, :allow_nil)) 17 | end 18 | end 19 | end 20 | 21 | private 22 | 23 | def acceptable_option?(value) 24 | Array(options[:accept]).include?(value) 25 | end 26 | end 27 | 28 | # Assign the class to a constant for tracking 29 | ::ActiveModel::Validations.const_set(:ReformAcceptanceValidator, klass) 30 | 31 | # Override the built-in validator 32 | ::ActiveModel::Validations.send(:remove_const, :AcceptanceValidator) 33 | ::ActiveModel::Validations.const_set(:AcceptanceValidator, klass) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reform::Rails 2 | 3 | [![Gitter Chat](https://badges.gitter.im/trailblazer/chat.svg)](https://gitter.im/trailblazer/chat) 4 | [![TRB Newsletter](https://img.shields.io/badge/TRB-newsletter-lightgrey.svg)](http://trailblazer.to/newsletter/) 5 | [![Build 6 | Status](https://travis-ci.org/trailblazer/reform-rails.svg)](https://travis-ci.org/trailblazer/reform-rails) 7 | [![Gem Version](https://badge.fury.io/rb/reform-rails.svg)](http://badge.fury.io/rb/reform-rails) 8 | 9 | _Rails-support for Reform_. 10 | 11 | Loads Rails-specific Reform files and includes modules like `Reform::Form::ActiveModel` automatically. 12 | 13 | Simply don't include this gem if you don't want to use the conventional Reform/Rails stack. For example in a Hanami environment or when using dry-validations, refrain from using this gem. 14 | 15 | ## Documentation 16 | 17 | The [full documentation](https://trailblazer.to/2.0/gems/reform/rails.html) can be found on the Trailblazer page. 18 | 19 | ## Installation 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | ```ruby 24 | gem 'reform-rails' 25 | ``` 26 | 27 | Reform-rails needs Reform >= 2.3. 28 | 29 | ## Contributing 30 | 31 | Please ensure that you test your changes against all supported ruby and rails versions 32 | 33 | You can run tests for a specific version of rails by running the following: 34 | 35 | ```shell 36 | BUNDLE_GEMFILE=Gemfile-rails7.0 bundle install 37 | BUNDLE_GEMFILE=Gemfile-rails7.0 bundle exec rake test 38 | ``` 39 | 40 | ## License 41 | 42 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 43 | -------------------------------------------------------------------------------- /lib/reform/form/orm.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::ORM 2 | def model_for_property(name) 3 | return model unless is_a?(Reform::Form::Composition) # i am too lazy for proper inheritance. there should be a ActiveRecord::Composition that handles this. 4 | 5 | model_name = options_for(name)[:on] 6 | model[model_name] 7 | end 8 | 9 | module UniquenessValidator 10 | # when calling validates it should create the Vali instance already and set @klass there! # TODO: fix this in AM. 11 | def validate(form) 12 | property = attributes.first 13 | 14 | # here is the thing: why does AM::UniquenessValidator require a filled-out record to work properly? also, why do we need to set 15 | # the class? it would be way easier to pass #validate a hash of attributes and get back an errors hash. 16 | # the class for the finder could either be infered from the record or set in the validator instance itself in the call to ::validates. 17 | record = form.model_for_property(property) 18 | record.send("#{property}=", form.send(property)) 19 | 20 | @klass = record.class # this is usually done in the super-sucky #setup method. 21 | super(record).tap do |res| 22 | if record.errors.present? 23 | error = if Gem::Version.new(ActiveModel::VERSION::STRING) >= Gem::Version.new('6.1.0') 24 | self.class.name.include?("Mongoid") ? record.errors.first.type : :taken 25 | else 26 | self.class.name.include?("Mongoid") ? record.errors.first.last : :taken 27 | end 28 | form.errors.add(property, error) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/fixtures/locales/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | # custom validation error messages 4 | 5 | activemodel: 6 | attributes: 7 | song: 8 | title: Custom Song Title 9 | album: 10 | title: Custom Album Title 11 | artist: 12 | name: Custom Artist Name 13 | errors: 14 | models: 15 | song: 16 | attributes: 17 | title: 18 | custom_error_message: Custom Error Message 19 | unique_validator_with_scope_array_test/song: 20 | attributes: 21 | title: 22 | taken: has already been taken 23 | uniqueness_validator_on_create_test/song: 24 | attributes: 25 | title: 26 | taken: has already been taken 27 | uniqueness_validator_on_update_with_duplicate_test/song: 28 | attributes: 29 | title: 30 | taken: has already been taken 31 | unique_validator_with_scope_test/song: 32 | attributes: 33 | title: 34 | taken: has already been taken 35 | unique_validator_with_scope_and_case_insensitive_test/song: 36 | attributes: 37 | title: 38 | taken: has already been taken 39 | uniqueness_validator_on_create_case_insensitive_test/song: 40 | attributes: 41 | title: 42 | taken: has already been taken 43 | uniqueness_validator_with_from_property_test/song: 44 | attributes: 45 | name: 46 | taken: has already been taken 47 | active_model_custom_validation_translations_test/album: 48 | attributes: 49 | title: 50 | too_short: is too short -------------------------------------------------------------------------------- /lib/reform/form/active_model/form_builder_methods.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::ActiveModel 2 | # Including FormBuilderMethods will allow using form instances with form_for, simple_form, etc. 3 | # in Rails. It will further try to translate Rails' suboptimal songs_attributes weirdness 4 | # back to normal `songs: ` naming in +#valiate+. 5 | module FormBuilderMethods 6 | def self.included(base) 7 | base.extend ClassMethods # ::model_name 8 | end 9 | 10 | module ClassMethods 11 | private 12 | 13 | # TODO: add that shit in Form#present, not by overriding ::property. 14 | def property(name, options={}, &block) 15 | super.tap do |definition| 16 | add_nested_attribute_compat(name) if definition[:nested] # TODO: fix that in Rails FB#1832 work. 17 | end 18 | end 19 | 20 | # The Rails FormBuilder "detects" nested attributes (which is what we want) by checking existance of a setter method. 21 | def add_nested_attribute_compat(name) 22 | define_method("#{name}_attributes=") {} # this is why i hate respond_to? in Rails. 23 | end 24 | end 25 | 26 | # Modify the incoming Rails params hash to be representable compliant. 27 | def deserialize!(params) 28 | # this only happens in a Hash environment. other engines have to overwrite this method. 29 | schema.each do |dfn| 30 | rename_nested_param_for!(params, dfn) 31 | end 32 | 33 | super(params) 34 | end 35 | 36 | private 37 | def rename_nested_param_for!(params, dfn) 38 | name = dfn[:name] 39 | nested_name = "#{name}_attributes" 40 | return unless params.has_key?(nested_name) 41 | 42 | value = params["#{name}_attributes"] 43 | value = value.values if dfn[:collection] 44 | 45 | params[name] = value 46 | end 47 | end 48 | end -------------------------------------------------------------------------------- /lib/reform/form/active_model/model_reflections.rb: -------------------------------------------------------------------------------- 1 | # ModelReflections will be the interface between the form object and form builders like simple_form. 2 | # 3 | # This module is meant to collect all dependencies simple_form needs in addition to the ActiveModel ones. 4 | # Goal is to collect all methods and define a reflection API so simple_form works with all ORMs and Reform 5 | # doesn't have to "guess" what simple_form and other form helpers need. 6 | class Reform::Form < Reform::Contract 7 | module ActiveModel::ModelReflections 8 | def self.included(base) 9 | base.extend ClassMethods 10 | base.send :register_feature, self # makes it work in nested forms. 11 | end 12 | 13 | module ClassMethods 14 | # Delegate reflect_on_association to the model class to support simple_form's 15 | # association input. 16 | def reflect_on_association(*args) 17 | model_name.to_s.constantize.reflect_on_association(*args) 18 | end 19 | 20 | # this is needed in simpleform to infer required fields. 21 | def validators_on(*args) 22 | validation_groups.collect { |k, group| group.instance_variable_get(:@validations).validators_on(*args) }.flatten 23 | end 24 | end 25 | 26 | # Delegate column for attribute to the model to support simple_form's 27 | # attribute type interrogation. 28 | def column_for_attribute(name) 29 | model_for_property(name).column_for_attribute(name) 30 | end 31 | 32 | def has_attribute?(name) 33 | model_for_property(name).has_attribute?(name) 34 | end 35 | 36 | def defined_enums 37 | return model.defined_enums unless is_a?(Reform::Form::Composition) 38 | 39 | mapper.each.with_object({}) { |m,h| h.merge! m.defined_enums } 40 | end 41 | 42 | # this should also contain to_param and friends as this is used by the form helpers. 43 | end 44 | 45 | ModelReflections = ActiveModel::ModelReflections 46 | end 47 | -------------------------------------------------------------------------------- /lib/reform/form/multi_parameter_attributes.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::MultiParameterAttributes 2 | # TODO: implement this with parse_filter, so we don't have to manually walk through the hash, etc. 3 | class DateTimeParamsFilter 4 | def call(params) 5 | params = params.dup # DISCUSS: not sure if that slows down form processing? 6 | date_attributes = {} 7 | 8 | params.each do |attribute, value| 9 | if value.is_a?(Hash) 10 | params[attribute] = call(value) # TODO: #validate should only handle local form params. 11 | elsif matches = attribute.match(/^(\w+)\(.i\)$/) 12 | date_attribute = matches[1] 13 | date_attributes[date_attribute] = params_to_date( 14 | params.delete("#{date_attribute}(1i)"), 15 | params.delete("#{date_attribute}(2i)"), 16 | params.delete("#{date_attribute}(3i)"), 17 | params.delete("#{date_attribute}(4i)"), 18 | params.delete("#{date_attribute}(5i)") 19 | ) 20 | end 21 | end 22 | 23 | date_attributes.each do |attribute, date| 24 | params[attribute] = date 25 | end 26 | params 27 | end 28 | 29 | private 30 | def params_to_date(year, month, day, hour, minute) 31 | date_fields = [year, month, day].map!(&:to_i) 32 | time_fields = [hour, minute].map!(&:to_i) 33 | 34 | if date_fields.any?(&:zero?) || !Date.valid_date?(*date_fields) 35 | return nil 36 | end 37 | 38 | if hour.blank? && minute.blank? 39 | Date.new(*date_fields) 40 | else 41 | args = date_fields + time_fields 42 | Time.zone ? Time.zone.local(*args) : 43 | Time.new(*args) 44 | end 45 | end 46 | end 47 | 48 | # this hooks into the format-specific #deserialize! method. 49 | def deserialize!(params) 50 | super DateTimeParamsFilter.new.call(params) # if params.is_a?(Hash) # this currently works for hash, only. 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | 3 | require 'debug' 4 | require 'minitest/autorun' 5 | require 'logger' 6 | # Load the rails application 7 | require "active_model/railtie" 8 | 9 | Bundler.require 10 | 11 | module Dummy 12 | class Application < Rails::Application 13 | config.eager_load = false 14 | config.active_support.deprecation = :stderr 15 | 16 | if config.respond_to?(:active_model) 17 | config.active_model.i18n_customize_full_message = true 18 | end 19 | end 20 | end 21 | 22 | # Initialize the rails application 23 | Dummy::Application.initialize! 24 | 25 | require 'active_record' 26 | class Artist < ActiveRecord::Base 27 | end 28 | 29 | class Song < ActiveRecord::Base 30 | belongs_to :artist 31 | end 32 | 33 | class Album < ActiveRecord::Base 34 | has_many :songs 35 | end 36 | 37 | ActiveRecord::Base.establish_connection :adapter => 'sqlite3', 38 | :database => ':memory:' 39 | ActiveRecord::Schema.verbose = false 40 | load "#{File.dirname(__FILE__)}/support/schema.rb" 41 | 42 | Minitest::Spec.class_eval do 43 | def self.rails_greater_6_0? 44 | Gem::Version.new(ActiveModel::VERSION::STRING) >= Gem::Version.new('6.1.0') 45 | end 46 | 47 | def self.rails5? 48 | ::ActiveModel::VERSION::MAJOR.in? [5] 49 | end 50 | 51 | def self.rails_greater_4_1? 52 | Gem::Version.new(ActiveModel::VERSION::STRING) >= Gem::Version.new('4.2.0') 53 | end 54 | end 55 | 56 | I18n.load_path << Dir['test/fixtures/locales/*.yml'] 57 | I18n.backend.load_translations 58 | 59 | class BaseTest < Minitest::Spec 60 | class AlbumForm < Reform::Form 61 | property :title 62 | 63 | property :hit do 64 | property :title 65 | end 66 | 67 | collection :songs do 68 | property :title 69 | end 70 | end 71 | 72 | Song = Struct.new(:title, :length) 73 | Album = Struct.new(:title, :hit, :songs, :band) 74 | Band = Struct.new(:label) 75 | Label = Struct.new(:name) 76 | Length = Struct.new(:minutes, :seconds) 77 | 78 | 79 | let (:hit) { Song.new("Roxanne") } 80 | end 81 | -------------------------------------------------------------------------------- /test/multi_parameter_attributes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ReformTest < Minitest::Spec 4 | let (:duran) { Struct.new(:name).new("Duran Duran") } 5 | let (:rio) { Struct.new(:title).new("Rio") } 6 | 7 | 8 | describe "Date" do 9 | Person = Struct.new(:date_of_birth) 10 | let (:form) { DateOfBirthForm.new(Person.new) } 11 | 12 | class DateOfBirthForm < Reform::Form 13 | # feature Reform::Form::ActiveModel::FormBuilderMethods 14 | feature Reform::Form::MultiParameterAttributes 15 | property :date_of_birth, type: Date, :multi_params => true 16 | end 17 | 18 | it "munges multi-param date fields into a valid Date attribute" do 19 | date_of_birth_params = { "date_of_birth(1i)"=>"1950", "date_of_birth(2i)"=>"1", "date_of_birth(3i)"=>"1" } 20 | form.validate(date_of_birth_params) 21 | _(form.date_of_birth).must_equal Date.civil(1950, 1, 1) 22 | end 23 | 24 | it "handles invalid Time input" do 25 | date_of_birth_params = { "date_of_birth(1i)"=>"1950", "date_of_birth(2i)"=>"99", "date_of_birth(3i)"=>"1" } 26 | form.validate(date_of_birth_params) 27 | assert_nil form.date_of_birth 28 | end 29 | end 30 | 31 | describe "DateTime" do 32 | Party = Struct.new(:start_time) 33 | let (:form) { PartyForm.new(Party.new) } 34 | 35 | class PartyForm < Reform::Form 36 | # feature Reform::Form::ActiveModel::FormBuilderMethods 37 | feature Reform::Form::MultiParameterAttributes 38 | property :start_time, type: DateTime, :multi_params => true 39 | end 40 | 41 | it "munges multi-param date and time fields into a valid Time attribute" do 42 | start_time_params = { "start_time(1i)"=>"2000", "start_time(2i)"=>"1", "start_time(3i)"=>"1", "start_time(4i)"=>"12", "start_time(5i)"=>"00" } 43 | time_format = "%Y-%m-%d %H:%M" 44 | form.validate(start_time_params) 45 | _(form.start_time.strftime(time_format)).must_equal DateTime.strptime("2000-01-01 12:00", time_format) 46 | end 47 | 48 | it "handles invalid Time input" do 49 | start_time_params = { "start_time(1i)"=>"2000", "start_time(2i)"=>"99", "start_time(3i)"=>"1", "start_time(4i)"=>"12", "start_time(5i)"=>"00" } 50 | form.validate(start_time_params) 51 | assert_nil form.start_time 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/model_validations_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ModelValidationsTest < Minitest::Spec 4 | 5 | class Album 6 | include ActiveModel::Validations 7 | attr_accessor :title, :artist, :other_attribute 8 | 9 | validates :title, :artist, presence: true 10 | validates :other_attribute, presence: true 11 | end 12 | 13 | class AlbumRating 14 | include ActiveModel::Validations 15 | 16 | attr_accessor :rating 17 | 18 | validates :rating, numericality: { greater_than_or_equal_to: 0 } 19 | 20 | end 21 | 22 | class AlbumForm < Reform::Form 23 | extend ActiveModel::ModelValidations 24 | 25 | property :title 26 | property :artist_name, from: :artist 27 | copy_validations_from Album 28 | end 29 | 30 | class CompositeForm < Reform::Form 31 | include Composition 32 | extend ActiveModel::ModelValidations 33 | 34 | model :album 35 | 36 | property :title, on: :album 37 | property :artist_name, from: :artist, on: :album 38 | property :rating, on: :album_rating 39 | 40 | copy_validations_from album: Album, album_rating: AlbumRating 41 | end 42 | 43 | let(:album) { Album.new } 44 | 45 | describe 'non-composite form' do 46 | 47 | let(:album_form) { AlbumForm.new(album) } 48 | 49 | it 'is not valid when title is not present' do 50 | _(album_form.validate(artist_name: 'test', title: nil)).must_equal false 51 | end 52 | 53 | it 'is not valid when artist_name is not present' do 54 | _(album_form.validate(artist_name: nil, title: 'test')).must_equal false 55 | end 56 | 57 | it 'is valid when title and artist_name is present' do 58 | _(album_form.validate(artist_name: 'test', title: 'test')).must_equal true 59 | end 60 | 61 | end 62 | 63 | describe 'composite form' do 64 | 65 | let(:album_rating) { AlbumRating.new } 66 | let(:composite_form) { CompositeForm.new(album: album, album_rating: album_rating) } 67 | 68 | it 'is valid when all attributes are correct' do 69 | _(composite_form.validate(artist_name: 'test', title: 'test', rating: 1)).must_equal true 70 | end 71 | 72 | it 'is invalid when rating is below 0' do 73 | _(composite_form.validate(artist_name: 'test', title: 'test', rating: -1)).must_equal false 74 | end 75 | 76 | it 'is invalid when artist_name is missing' do 77 | _(composite_form.validate(artist_name: nil, title: 'test', rating: 1)).must_equal false 78 | end 79 | 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # 0.3.1 2 | 3 | * Fix: ActiveModel acceptance validator wasn't properly monkey patched 4 | Now this will work in environments where active model and active record is 5 | not loaded by default. In addition, it will work with all the Rails versions 6 | we support 7 | 8 | # 0.3.0 9 | 10 | * Add `conditions` option to Reform Uniqueness validation. 11 | * ActiveModel acceptance validation works 12 | 13 | # 0.2.6 14 | 15 | * Allow to override `#persisted?` and friends with modules. 16 | 17 | # 0.2.5 18 | 19 | * Fix: Delegating from form object causes ArgumentError with 0.2.4 (https://github.com/trailblazer/reform-rails/issues/99) 20 | 21 | # 0.2.4 22 | 23 | * Fix keyword argument warning in `method_missing` (https://github.com/trailblazer/reform-rails/pull/97) 24 | * Internal: Replace Uber::Delegates with Forwardable in Form::ActiveModel 25 | 26 | # 0.2.3 27 | 28 | * Fix deprecation warning related to `respond_to?` 29 | 30 | # 0.2.2 31 | 32 | * Support ActiveRecord 6.1 33 | 34 | # 0.2.1 35 | 36 | * Error's full_message with translation fixed thanks to [@marcelolx](https://github.com/trailblazer/reform-rails/pull/85) 37 | 38 | # 0.2.0 39 | 40 | * Needs Reform >= 2.3.0. 41 | * make the inclusion of ActiveModel form builder modules optional when using dry-validation. This can be controlled via `config.reform.enable_active_model_builder_methods = true`. 42 | * delegate `validates_each` method and allow it to be called outside a validation block. 43 | * add `case_sensitive` option to Reform Uniqueness validation. Defaults to true. 44 | * fix bug in uniqueness validation where form has different attribute name to column 45 | * improve handling of persisted records in uniqueness validator 46 | * remove params.merge! as it's deprecated in rails 5 47 | * update to support reform 2.3, the new API means that `errors.add` is delegated to ActiveModel::Errors to support rails 5 48 | * Fix nested form validation (#53) 49 | * Errors supports symbol and string lookup (PR #77) 50 | * Implement respond_to that delegates to AMV errors (PR #78) 51 | * Drop support for activemodel before 5.0 52 | 53 | # 0.1.8 54 | * Drop support to mongoid < 4. 55 | 56 | # 0.1.7 (0.1.6 Yanked) 57 | 58 | * Fix a bug where requiring `form/active_model/validations` in a non-Rails environment wouldn't load all necessary files. 59 | 60 | # 0.1.5 61 | 62 | * Allow using Reform-Rails without Rails (it used to crash when not loading `rails.rb`). 63 | 64 | # 0.1.4 65 | 66 | * Allow setting `config.reform` in initializers, too. 67 | 68 | # 0.1.3 69 | 70 | * Introduce a railtie to load either `ActiveModel::Validations` *or* `Dry::Validations`. This can be controlled via `config.reform.validations = :dry`. 71 | 72 | # 0.1.2 73 | 74 | * Allow Reform-2.2.0.rc1 in gemspec. 75 | 76 | # 0.1.1 77 | 78 | * First working release. 79 | -------------------------------------------------------------------------------- /lib/reform/rails/railtie.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | module Rails 3 | class Railtie < ::Rails::Railtie 4 | config.reform = ActiveSupport::OrderedOptions.new 5 | 6 | initializer "reform.form_extensions", after: :load_config_initializers do 7 | validations = config.reform.validations || :active_model 8 | 9 | require "reform/form/multi_parameter_attributes" 10 | 11 | if validations == :active_model 12 | active_model! 13 | elsif validations == :dry 14 | enable_form_builder_methods = config.reform.enable_active_model_builder_methods || false 15 | 16 | dry!(enable_form_builder_methods) 17 | else 18 | warn "[Reform::Rails] No validation backend set. Please do so via `config.reform.validations = :active_model`." 19 | end 20 | end 21 | 22 | def active_model! 23 | require "reform/form/active_model/form_builder_methods" 24 | require "reform/form/active_model" 25 | 26 | require "reform/form/active_model/model_validations" 27 | require "reform/form/active_model/validations" 28 | 29 | require "reform/active_record" if defined?(::ActiveRecord) 30 | require "reform/mongoid" if defined?(::Mongoid) 31 | 32 | Reform::Form.class_eval do 33 | include Reform::Form::ActiveModel 34 | include Reform::Form::ActiveModel::FormBuilderMethods 35 | include Reform::Form::ActiveRecord if defined?(::ActiveRecord) 36 | include Reform::Form::Mongoid if defined?(::Mongoid) 37 | include Reform::Form::ActiveModel::Validations 38 | end 39 | end 40 | 41 | def dry!(enable_am = true) 42 | if enable_am 43 | require "reform/form/active_model/form_builder_methods" # this is for simple_form, etc. 44 | 45 | # This adds Form#persisted? and all the other crap #form_for depends on. Grrrr. 46 | require "reform/form/active_model" # DISCUSS: only when using simple_form. 47 | end 48 | 49 | require "reform/form/dry" 50 | 51 | Reform::Form.class_eval do 52 | if enable_am 53 | include Reform::Form::ActiveModel 54 | include Reform::Form::ActiveModel::FormBuilderMethods 55 | end 56 | 57 | include Reform::Form::Dry 58 | end 59 | end 60 | 61 | initializer "reform.patch_acceptance_validator" do 62 | require "reform/form/active_model/acceptance_validator_patch" 63 | 64 | if defined?(::ActiveModel::Validations::AcceptanceValidator) 65 | # Remove this branch after we support Rails >= 7.1 66 | Reform::Form::ActiveModel::AcceptanceValidatorPatch.apply! 67 | else 68 | ActiveSupport.on_load(:active_record) { Reform::Form::ActiveModel::AcceptanceValidatorPatch.apply! } 69 | Rails.application.config.to_prepare { Reform::Form::ActiveModel::AcceptanceValidatorPatch.apply! } 70 | end 71 | end 72 | end # Railtie 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test-rails5: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v5 10 | - uses: supercharge/mongodb-github-action@1.12.0 11 | with: 12 | mongodb-version: "4.4" 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: "2.5" 16 | - run: BUNDLE_GEMFILE=Gemfile-rails5 bundle install 17 | - run: BUNDLE_GEMFILE=Gemfile-rails5 bundle exec rake 18 | 19 | test-rails6_0: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v5 23 | - uses: supercharge/mongodb-github-action@1.12.0 24 | with: 25 | mongodb-version: "4.4" 26 | - uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: "2.6" 29 | - run: BUNDLE_GEMFILE=Gemfile-rails6.0 bundle install 30 | - run: BUNDLE_GEMFILE=Gemfile-rails6.0 bundle exec rake 31 | 32 | test-rails6_1: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v5 36 | - uses: supercharge/mongodb-github-action@1.12.0 37 | with: 38 | mongodb-version: "4.4" 39 | - uses: ruby/setup-ruby@v1 40 | with: 41 | ruby-version: "3.0" 42 | - run: BUNDLE_GEMFILE=Gemfile-rails6.1 bundle install 43 | - run: BUNDLE_GEMFILE=Gemfile-rails6.1 bundle exec rake 44 | 45 | test-rails7_0: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v5 49 | - uses: supercharge/mongodb-github-action@1.12.0 50 | with: 51 | mongodb-version: "4.4" 52 | - uses: ruby/setup-ruby@v1 53 | with: 54 | ruby-version: "3.0" 55 | - run: BUNDLE_GEMFILE=Gemfile-rails7.0 bundle install 56 | - run: BUNDLE_GEMFILE=Gemfile-rails7.0 bundle exec rake 57 | 58 | test-rails7_1: 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v5 62 | - uses: supercharge/mongodb-github-action@1.12.0 63 | with: 64 | mongodb-version: "4.4" 65 | - uses: ruby/setup-ruby@v1 66 | with: 67 | ruby-version: "3.2" 68 | - run: BUNDLE_GEMFILE=Gemfile-rails7.1 bundle install 69 | - run: BUNDLE_GEMFILE=Gemfile-rails7.1 bundle exec rake 70 | 71 | test-rails7_2: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/checkout@v5 75 | - uses: supercharge/mongodb-github-action@1.12.0 76 | with: 77 | mongodb-version: "4.4" 78 | - uses: ruby/setup-ruby@v1 79 | with: 80 | ruby-version: "3.3" 81 | - run: BUNDLE_GEMFILE=Gemfile-rails7.2 bundle install 82 | - run: BUNDLE_GEMFILE=Gemfile-rails7.2 bundle exec rake 83 | 84 | test-rails8: 85 | runs-on: ubuntu-latest 86 | steps: 87 | - uses: actions/checkout@v5 88 | - uses: supercharge/mongodb-github-action@1.12.0 89 | with: 90 | mongodb-version: "4.4" 91 | - uses: ruby/setup-ruby@v1 92 | with: 93 | ruby-version: "3.3" 94 | - run: BUNDLE_GEMFILE=Gemfile-rails8 bundle install 95 | - run: BUNDLE_GEMFILE=Gemfile-rails8 bundle exec rake 96 | -------------------------------------------------------------------------------- /lib/reform/form/active_model.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::ActiveModel 2 | def self.included(base) 3 | base.class_eval do 4 | extend ClassMethods 5 | register_feature ActiveModel 6 | 7 | delegations = Module.new do 8 | delegate :persisted?, :to_key, :to_param, :id, to: :model 9 | end 10 | 11 | include delegations # now, those methods (e.g. {#persisted?}) can be overridden by another module. 12 | 13 | def to_model # this is called somewhere in FormBuilder and ActionController. 14 | self 15 | end 16 | end 17 | end 18 | 19 | 20 | module ClassMethods 21 | # this module is only meant to extend (not include). # DISCUSS: is this a sustainable concept? 22 | def self.extended(base) 23 | base.class_eval do 24 | extend Uber::InheritableAttribute 25 | inheritable_attr :model_options 26 | end 27 | end 28 | 29 | # DISCUSS: can we achieve that somehow via features in build_inline? 30 | def property(*) 31 | super.tap do |dfn| 32 | return dfn unless dfn[:nested] 33 | _name = dfn[:name] 34 | dfn[:nested].instance_eval do 35 | @_name = _name.singularize.camelize 36 | # this adds Form::name for AM::Validations and I18N. 37 | def name 38 | @_name 39 | end 40 | end 41 | end 42 | end 43 | 44 | # moved from reform as not applicable to dry 45 | def validates(*args, &block) 46 | validation(name: :default, inherit: true) { validates *args, &block } 47 | end 48 | 49 | def validate(*args, &block) 50 | validation(name: :default, inherit: true) { validate *args, &block } 51 | end 52 | 53 | def validates_with(*args, &block) 54 | validation(name: :default, inherit: true) { validates_with *args, &block } 55 | end 56 | 57 | def validates_each(*args, &block) 58 | validation(name: :default, inherit: true) { validates_each *args, &block } 59 | end 60 | 61 | # Set a model name for this form if the infered is wrong. 62 | # 63 | # class CoverSongForm < Reform::Form 64 | # model :song 65 | # 66 | # or we can setup a isolated namespace model ( which defined in isolated rails egine ) 67 | # 68 | # class CoverSongForm < Reform::Form 69 | # model "api/v1/song", namespace: "api" 70 | def model(main_model, options={}) 71 | self.model_options = [main_model, options] 72 | end 73 | 74 | def model_name 75 | if model_options 76 | form_name = model_options.first.to_s.camelize 77 | namespace = model_options.last[:namespace].present? ? model_options.last[:namespace].to_s.camelize.constantize : nil 78 | else 79 | if name 80 | form_name = name.sub(/(::)?Form$/, "") # Song::Form => "Song" 81 | namespace = nil 82 | else # anonymous forms. let's drop AM and forget about all this. 83 | form_name = "reform" 84 | namespace = nil 85 | end 86 | end 87 | 88 | active_model_name_for(form_name, namespace) 89 | end 90 | 91 | private 92 | def active_model_name_for(string, namespace=nil) 93 | ::ActiveModel::Name.new(self, namespace, string) 94 | end 95 | end # ClassMethods 96 | 97 | 98 | def model_name(*args) 99 | self.class.model_name(*args) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/reform/form/active_model/model_validations.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::ActiveModel 2 | module ModelValidations 3 | # TODO: extract Composition behaviour. 4 | # reduce code in Mapping. 5 | 6 | class ValidationCopier 7 | 8 | def self.copy(form_class, mapping, models) 9 | if models.is_a?(Hash) 10 | models.each do |model_name, model| 11 | new(form_class, mapping, model, model_name).copy 12 | end 13 | else 14 | new(form_class, mapping, models).copy 15 | end 16 | end 17 | 18 | def initialize(form_class, mapping, model, model_name=nil) 19 | @form_class = form_class 20 | @mapping = mapping 21 | @model = model 22 | @model_name = model_name 23 | end 24 | 25 | def copy 26 | @model.validators.each(&method(:add_validator)) 27 | end 28 | 29 | private 30 | 31 | def add_validator(validator) 32 | if validator.respond_to?(:attributes) 33 | add_native_validator validator 34 | else 35 | add_custom_validator validator 36 | end 37 | end 38 | 39 | def add_native_validator validator 40 | attributes = inverse_map_attributes(validator.attributes) 41 | if attributes.any? 42 | @form_class.validates(*attributes, {validator.kind => validator.options}) 43 | end 44 | end 45 | 46 | def add_custom_validator validator 47 | @form_class.validates(nil, {validator.kind => validator.options}) 48 | end 49 | 50 | def inverse_map_attributes(attributes) 51 | @mapping.inverse_image(create_attributes(attributes)) 52 | end 53 | 54 | def create_attributes(attributes) 55 | attributes.map do |attribute| 56 | [@model_name, attribute].compact 57 | end 58 | end 59 | 60 | end 61 | 62 | class Mapping 63 | def self.from_representable_attrs(attrs) 64 | new.tap do |mapping| 65 | attrs.each do |dfn| 66 | from = dfn[:name].to_sym 67 | to = [dfn[:on], (dfn[:private_name] || dfn[:name])].compact.map(&:to_sym) 68 | mapping.add(from, to) 69 | end 70 | end 71 | end 72 | 73 | def initialize 74 | @forward_map = {} 75 | @inverse_map = {} 76 | end 77 | 78 | # from is a symbol attribute 79 | # to is an 1 or 2 element array, depending on whether the attribute is 'namespaced', as it is with composite forms. 80 | # eg, add(:phone_number, [:person, :phone]) 81 | def add(from, to) 82 | raise 'Mapping is not one-to-one' if @forward_map.has_key?(from) || @inverse_map.has_key?(to) 83 | @forward_map[from] = to 84 | @inverse_map[to] = from 85 | end 86 | 87 | def forward_image(attrs) 88 | @forward_map.values_at(*attrs).compact 89 | end 90 | 91 | def forward(attr) 92 | @forward_map[attr] 93 | end 94 | 95 | def inverse_image(attrs) 96 | @inverse_map.values_at(*attrs).compact 97 | end 98 | 99 | def inverse(attr) 100 | @inverse_map[attr] 101 | end 102 | 103 | end 104 | 105 | def copy_validations_from(models) 106 | ValidationCopier.copy(self, Mapping.from_representable_attrs(definitions), models) 107 | end 108 | 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/reform/form/validation/unique_validator.rb: -------------------------------------------------------------------------------- 1 | # === Unique Validation 2 | # Reform's own implementation for uniqueness which does not write to model 3 | # 4 | # == Usage 5 | # Pass a true boolean value to validate a field against all values available in 6 | # the database: 7 | # validates :title, unique: true 8 | # 9 | # == Options 10 | # 11 | # = Case Sensitivity 12 | # Case sensitivity is true by default, but can be set to false: 13 | # 14 | # validates :title, unique: { case_sensitive: false } 15 | # 16 | # = Scope 17 | # A scope can be used to filter the records that need to be compare with the 18 | # current value to validate. A scope array can have one to many fields define. 19 | # 20 | # A scope can be define the following ways: 21 | # validates :title, unique: { scope: :album_id } 22 | # validates :title, unique: { scope: [:album_id] } 23 | # validates :title, unique: { scope: [:album_id, ...] } 24 | # 25 | # All fields included in a scope must be declared as a property like this: 26 | # property :album_id 27 | # validates :title, unique: { scope: :album_id } 28 | # 29 | # Just remove write access to the property if the field must not be change: 30 | # property :album_id, writeable: false 31 | # validates :title, unique: { scope: :album_id } 32 | # 33 | # This use case is useful if album_id is set to a Song this way: 34 | # song = album.songs.new 35 | # album_id is automatically set and can't be change by the operation 36 | # 37 | # = Conditions 38 | # A condition can be passed to filter the records with partial indexes 39 | # 40 | # Conditions can be define the following ways: 41 | # validates :title, unique: { conditions: -> { where(archived_at: nil) } } 42 | # - This will check that the title is unique for non archived records 43 | # validates :title, unique: { 44 | # conditions: ->(record) { 45 | # published_at = record.published_at 46 | # where(published_at: published_at.beginning_of_year..published_at.end_of_year) 47 | # } 48 | # } 49 | # 50 | 51 | class Reform::Form::UniqueValidator < ActiveModel::EachValidator 52 | def validate_each(form, attribute, value) 53 | model = form.model_for_property(attribute) 54 | original_attribute = form.options_for(attribute)[:private_name] 55 | 56 | # search for models with attribute equals to form field value 57 | query = if options[:case_sensitive] == false && value 58 | model.class.where("lower(#{original_attribute}) = ?", value.downcase) 59 | else 60 | model.class.where(original_attribute => value) 61 | end 62 | 63 | # if model persisted, query should bypass model 64 | if model.persisted? 65 | query = query.where("#{model.class.primary_key} != ?", model.id) 66 | end 67 | 68 | # apply scope if options has been declared 69 | Array(options[:scope]).each do |field| 70 | # add condition to only check unique value with the same scope 71 | query = query.where(field => form.send(field)) 72 | end 73 | 74 | if options[:conditions] 75 | conditions = options[:conditions] 76 | 77 | query = if conditions.arity.zero? 78 | query.instance_exec(&conditions) 79 | else 80 | query.instance_exec(form, &conditions) 81 | end 82 | end 83 | 84 | form.errors.add(attribute, :taken) if query.count > 0 85 | end 86 | end 87 | 88 | # FIXME: ActiveModel loads validators via const_get(#{name}Validator). This magic forces us to 89 | # make the new :unique validator available here. 90 | Reform::Form::ActiveModel::Validations::Validator.class_eval do 91 | UniqueValidator = Reform::Form::UniqueValidator 92 | end 93 | -------------------------------------------------------------------------------- /test/active_model_custom_validation_translations_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ActiveModelCustomValidationTranslationsTest < Minitest::Spec 4 | module SongForm 5 | class WithBlock < Reform::Form 6 | model :song 7 | property :title 8 | 9 | validate do 10 | errors.add :title, :blank 11 | errors.add :title, :custom_error_message 12 | end 13 | end 14 | 15 | class WithLambda < Reform::Form 16 | model :song 17 | property :title 18 | 19 | validate ->{ errors.add :title, :blank 20 | errors.add :title, :custom_error_message } 21 | end 22 | 23 | class WithMethod < Reform::Form 24 | model :song 25 | property :title 26 | 27 | validate :custom_validation_method 28 | def custom_validation_method 29 | errors.add :title, :blank 30 | errors.add :title, :custom_error_message 31 | end 32 | end 33 | end 34 | 35 | class AlbumForm < Reform::Form 36 | model :album 37 | property :title 38 | 39 | validate do 40 | errors.add :title, :too_short, count: 15 41 | end 42 | 43 | property :artist, :populate_if_empty => Artist do 44 | property :name 45 | 46 | validate do 47 | errors.add :name, :blank 48 | end 49 | end 50 | 51 | collection :songs, :populate_if_empty => Song do 52 | property :title 53 | 54 | validate do 55 | errors.add :title, :blank 56 | end 57 | end 58 | end 59 | 60 | describe 'when using a default translation' do 61 | it 'translates the error message when custom validation is used with block' do 62 | form = SongForm::WithBlock.new(Song.new) 63 | form.validate({}) 64 | _(form.errors[:title]).must_include "can't be blank" 65 | end 66 | 67 | it 'translates the error message when custom validation is used with lambda' do 68 | form = SongForm::WithLambda.new(Song.new) 69 | form.validate({}) 70 | _(form.errors[:title]).must_include "can't be blank" 71 | end 72 | 73 | it 'translates the error message when custom validation is used with method' do 74 | form = SongForm::WithMethod.new(Song.new) 75 | form.validate({}) 76 | _(form.errors[:title]).must_include "can't be blank" 77 | end 78 | end 79 | 80 | describe 'when using a custom translation' do 81 | it 'translates the error message when custom validation is used with block' do 82 | form = SongForm::WithBlock.new(Song.new) 83 | form.validate({}) 84 | _(form.errors[:title]).must_include "Custom Error Message" 85 | end 86 | 87 | it 'translates the error message when custom validation is used with lambda' do 88 | form = SongForm::WithLambda.new(Song.new) 89 | form.validate({}) 90 | _(form.errors[:title]).must_include "Custom Error Message" 91 | end 92 | 93 | it 'translates the error message when custom validation is used with method' do 94 | form = SongForm::WithMethod.new(Song.new) 95 | form.validate({}) 96 | _(form.errors[:title]).must_include "Custom Error Message" 97 | end 98 | end 99 | 100 | describe 'when calling full_messages' do 101 | it 'translates the field name' do 102 | form = SongForm::WithBlock.new(Song.new) 103 | form.validate({}) 104 | _(form.errors.full_messages).must_include "Custom Song Title can't be blank" 105 | end 106 | 107 | describe 'when using nested_model_attributes' do 108 | it 'translates the nested model attributes name' do 109 | album = Album.create(title: 'Greatest Hits') 110 | form = AlbumForm.new(album, artist: Artist.new, songs: [Song.new]) 111 | form.validate({}) 112 | _(form.errors.full_messages).must_include "Custom Album Title is too short (minimum is 15 characters)" 113 | _(form.errors.full_messages).must_include "Custom Song Title can't be blank" 114 | _(form.errors.full_messages).must_include "Custom Artist Name can't be blank" 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/model_reflections_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | # Reform::ModelReflections will be the interface between the form object and form builders like simple_form. 4 | class ModelReflectionTest < Minitest::Spec 5 | class SongForm < Reform::Form 6 | include Reform::Form::ActiveRecord 7 | include Reform::Form::ActiveModel::ModelReflections 8 | 9 | model :song 10 | 11 | property :title 12 | property :artist do 13 | property :name 14 | end 15 | end 16 | 17 | module ColumnForAttribute 18 | def column_for_attribute(*args) 19 | "#{self.class}: #{args.inspect}" 20 | end 21 | end 22 | 23 | module HasAttribute 24 | def has_attribute?(*args) 25 | "#{self.class}: has #{args.inspect}" 26 | end 27 | end 28 | 29 | module DefinedEnums 30 | def defined_enums 31 | {self.class => []} 32 | end 33 | end 34 | 35 | describe "#column_for_attribute" do 36 | let (:artist) { Artist.new } 37 | let (:song) { Song.new(artist: artist) } 38 | let (:form) { SongForm.new(song) } 39 | 40 | # delegate to model. 41 | it do 42 | song.extend(ColumnForAttribute) 43 | artist.extend(ColumnForAttribute) 44 | 45 | _(form.column_for_attribute(:title)).must_equal "Song: [:title]" 46 | _(form.artist.column_for_attribute(:name)).must_equal "Artist: [:name]" 47 | end 48 | end 49 | 50 | describe "#has_attribute?" do 51 | let (:artist) { Artist.new } 52 | let (:song) { Song.new(artist: artist) } 53 | let (:form) { SongForm.new(song) } 54 | 55 | # delegate to model. 56 | it do 57 | song.extend(HasAttribute) 58 | artist.extend(HasAttribute) 59 | 60 | _(form.has_attribute?(:title)).must_equal "Song: has [:title]" 61 | _(form.artist.has_attribute?(:name)).must_equal "Artist: has [:name]" 62 | end 63 | end 64 | 65 | describe "#defined_enums" do 66 | let (:artist) { Artist.new } 67 | let (:song) { Song.new(artist: artist) } 68 | let (:form) { SongForm.new(song) } 69 | 70 | # delegate to model. 71 | it do 72 | song.extend(DefinedEnums) 73 | artist.extend(DefinedEnums) 74 | 75 | _(form.defined_enums).must_include Song 76 | _(form.artist.defined_enums).must_include Artist 77 | end 78 | end 79 | 80 | describe ".reflect_on_association" do 81 | let (:artist) { Artist.new } 82 | let (:song) { Song.new(artist: artist) } 83 | let (:form) { SongForm.new(song) } 84 | 85 | # delegate to model class. 86 | it do 87 | reflection = form.class.reflect_on_association(:artist) 88 | _(reflection).must_be_kind_of ActiveRecord::Reflection::AssociationReflection 89 | end 90 | end 91 | 92 | class SongWithArtistForm < Reform::Form 93 | include Reform::Form::ActiveRecord 94 | include Reform::Form::ModelReflections 95 | include Reform::Form::Composition 96 | 97 | model :artist 98 | 99 | property :name, on: :artist 100 | property :title, on: :song 101 | end 102 | 103 | describe "#column_for_attribute with composition" do 104 | let (:artist) { Artist.new } 105 | let (:song) { Song.new } 106 | let (:form) { SongWithArtistForm.new(artist: artist, song: song) } 107 | 108 | # delegates to respective model. 109 | it do 110 | song.extend(ColumnForAttribute) 111 | artist.extend(ColumnForAttribute) 112 | 113 | 114 | _(form.column_for_attribute(:name)).must_equal "Artist: [:name]" 115 | _(form.column_for_attribute(:title)).must_equal "Song: [:title]" 116 | end 117 | end 118 | 119 | describe "#defined_enums with composition" do 120 | let (:artist) { Artist.new } 121 | let (:song) { Song.new } 122 | let (:form) { SongWithArtistForm.new(artist: artist, song: song) } 123 | 124 | # delegates to respective model. 125 | it do 126 | song.extend(DefinedEnums) 127 | artist.extend(DefinedEnums) 128 | 129 | 130 | _(form.defined_enums).must_include Song 131 | _(form.defined_enums).must_include Artist 132 | end 133 | end 134 | 135 | describe "::validators_on" do 136 | it { assert SongWithArtistForm.validators_on } 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/form_builder_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class FormBuilderCompatTest < BaseTest 4 | class AlbumForm < Reform::Form 5 | feature Reform::Form::ActiveModel::FormBuilderMethods 6 | 7 | feature Reform::Form::MultiParameterAttributes 8 | 9 | property :artist do 10 | property :name 11 | validates :name, :presence => true 12 | end 13 | 14 | collection :songs do 15 | # feature Reform::Form::ActiveModel::FormBuilderMethods 16 | property :title 17 | property :release_date, :multi_params => true 18 | validates :title, :presence => true 19 | end 20 | 21 | class LabelForm < Reform::Form 22 | property :name 23 | 24 | validates :name, presence: true 25 | end 26 | 27 | property :label, form: LabelForm 28 | 29 | property :band do 30 | property :label do 31 | property :name 32 | 33 | property :location do 34 | property :postcode 35 | end 36 | end 37 | end 38 | end 39 | 40 | 41 | let (:song) { OpenStruct.new } 42 | let (:form) { 43 | AlbumForm.new(OpenStruct.new( 44 | :artist => Artist.new(:name => "Propagandhi"), 45 | :songs => [song], 46 | :label => Label.new, 47 | 48 | :band => Band.new(OpenStruct.new(location: OpenStruct.new)) 49 | )) 50 | } 51 | 52 | it "respects _attributes params hash" do 53 | form.validate( 54 | "artist_attributes" => {"name" => "Blink 182"}, 55 | "songs_attributes" => {"0" => {"title" => "Damnit"}}, 56 | "band_attributes" => {"label_attributes" => {"name" => "Epitaph", "location_attributes" => {"postcode" => 2481}}}) 57 | 58 | _(form.artist.name).must_equal "Blink 182" 59 | _(form.songs.first.title).must_equal "Damnit" 60 | _(form.band.label.name).must_equal "Epitaph" 61 | _(form.band.label.location.postcode).must_equal 2481 62 | end 63 | 64 | it "allows nested collection and property to be missing" do 65 | form.validate({}) 66 | 67 | _(form.artist.name).must_equal "Propagandhi" 68 | 69 | _(form.songs.size).must_equal 1 70 | _(form.songs[0].model).must_equal song # this is a weird test. 71 | end 72 | 73 | it "defines _attributes= setter so Rails' FB works properly" do 74 | _(form).must_respond_to("artist_attributes=") 75 | _(form).must_respond_to("songs_attributes=") 76 | _(form).must_respond_to("label_attributes=") 77 | end 78 | 79 | describe "deconstructed datetime parameters" do 80 | let(:form_attributes) do 81 | { 82 | "artist_attributes" => {"name" => "Blink 182"}, 83 | "songs_attributes" => {"0" => {"title" => "Damnit", "release_date(1i)" => release_year, 84 | "release_date(2i)" => release_month, "release_date(3i)" => release_day, 85 | "release_date(4i)" => release_hour, "release_date(5i)" => release_minute}} 86 | } 87 | end 88 | let(:release_year) { "1997" } 89 | let(:release_month) { "9" } 90 | let(:release_day) { "27" } 91 | let(:release_hour) { nil } 92 | let(:release_minute) { nil } 93 | 94 | describe "with valid date parameters" do 95 | it "creates a date" do 96 | form.validate(form_attributes) 97 | 98 | _(form.songs.first.release_date).must_equal Date.new(1997, 9, 27) 99 | end 100 | end 101 | 102 | describe "with valid datetime parameters" do 103 | let(:release_hour) { "10" } 104 | let(:release_minute) { "11" } 105 | 106 | it "creates a datetime" do 107 | form.validate(form_attributes) 108 | 109 | _(form.songs.first.release_date).must_equal DateTime.new(1997, 9, 27, 10, 11) 110 | end 111 | end 112 | 113 | %w(year month day).each do |date_attr| 114 | describe "when the #{date_attr} is missing" do 115 | let(:"release_#{date_attr}") { "" } 116 | 117 | it "rejects the date" do 118 | form.validate(form_attributes) 119 | 120 | _(form.songs.first.release_date).must_be_nil 121 | end 122 | end 123 | end 124 | 125 | 126 | # doesn't modify original params. 127 | it do 128 | original = form_attributes.inspect 129 | 130 | form.validate(form_attributes) 131 | _(form_attributes.inspect).must_equal original 132 | end 133 | end 134 | 135 | it "returns flat errors hash" do 136 | _(form.validate( 137 | "artist_attributes" => {"name" => ""}, 138 | "songs_attributes" => {"0" => {"title" => ""}} 139 | )).must_equal false 140 | _(form.errors.messages).must_equal(:"artist.name" => ["can't be blank"], :"songs.title" => ["can't be blank"], :"label.name"=>["can't be blank"]) 141 | end 142 | 143 | it 'fails when only nested form fails' do 144 | _(form.validate( 145 | "artist_attributes" => {"name" => "Ketama 126"}, 146 | "songs_attributes" => {"0" => {"title" => "66 cl"}} 147 | )).must_equal false 148 | _(form.errors.messages).must_equal(:"label.name"=>["can't be blank"]) 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /test/active_model_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module IsolatedRailsEngine 4 | def self.use_relative_model_naming? 5 | true 6 | end 7 | 8 | class Lyric < ActiveRecord::Base 9 | end 10 | end 11 | 12 | module NormalRailsEngine 13 | class Lyric < ActiveRecord::Base 14 | end 15 | end 16 | 17 | 18 | class NewActiveModelTest < Minitest::Spec # TODO: move to test/rails/ 19 | class SongForm < Reform::Form 20 | include Reform::Form::ActiveModel 21 | 22 | property :name 23 | end 24 | 25 | let (:artist) { Artist.create(:name => "Frank Zappa") } 26 | let (:form) { SongForm.new(artist) } 27 | 28 | it do 29 | _(form.persisted?).must_equal true 30 | _(form.to_key).must_equal [artist.id] 31 | _(form.to_param).must_equal "#{artist.id}" 32 | _(form.to_model).must_equal form 33 | _(form.id).must_equal artist.id 34 | _(form.model_name).must_equal form.class.model_name 35 | end 36 | 37 | describe "::model_name" do 38 | it { _(form.class.model_name).must_be_kind_of ActiveModel::Name } 39 | it { _(form.class.model_name.to_s).must_equal "NewActiveModelTest::Song" } 40 | 41 | let (:class_with_model) { 42 | Class.new(Reform::Form) do 43 | include Reform::Form::ActiveModel 44 | 45 | model :album 46 | end 47 | } 48 | 49 | it { _(class_with_model.model_name).must_be_kind_of ActiveModel::Name } 50 | it { _(class_with_model.model_name.to_s).must_equal "Album" } 51 | 52 | let (:class_with_isolated_model) { 53 | Class.new(Reform::Form) do 54 | include Reform::Form::ActiveModel 55 | 56 | model "isolated_rails_engine/lyric", namespace: "isolated_rails_engine" 57 | end 58 | } 59 | 60 | it { _(class_with_isolated_model.model_name).must_be_kind_of ActiveModel::Name } 61 | it { _(class_with_isolated_model.model_name.to_s).must_equal "IsolatedRailsEngine::Lyric" } 62 | 63 | let (:class_with_namespace_model) { 64 | Class.new(Reform::Form) do 65 | include Reform::Form::ActiveModel 66 | 67 | model "normal_rails_engine/lyric" 68 | end 69 | } 70 | 71 | it { _(class_with_namespace_model.model_name).must_be_kind_of ActiveModel::Name } 72 | it { _(class_with_namespace_model.model_name.to_s).must_equal "NormalRailsEngine::Lyric" } 73 | 74 | let (:subclass_of_class_with_model) { 75 | Class.new(class_with_model) 76 | } 77 | 78 | it { _(subclass_of_class_with_model.model_name).must_be_kind_of ActiveModel::Name } 79 | it { _(subclass_of_class_with_model.model_name.to_s).must_equal 'Album' } 80 | 81 | it { _(form.class.model_name.route_key).must_equal "new_active_model_test_songs" } 82 | it { _(class_with_model.model_name.route_key).must_equal "albums" } 83 | it { _(class_with_isolated_model.model_name.route_key).must_equal "lyrics" } 84 | it { _(class_with_namespace_model.model_name.route_key).must_equal "normal_rails_engine_lyrics" } 85 | it { _(subclass_of_class_with_model.model_name.route_key).must_equal 'albums' } 86 | 87 | describe "class named Song::Form" do 88 | it do 89 | _(class Form < Reform::Form 90 | include Reform::Form::ActiveModel 91 | self 92 | end.model_name.to_s).must_equal "NewActiveModelTest" 93 | end 94 | end 95 | 96 | 97 | describe "inline with model" do 98 | let (:form_class) { 99 | Class.new(Reform::Form) do 100 | include Reform::Form::ActiveModel 101 | 102 | property :song do 103 | include Reform::Form::ActiveModel 104 | model :hit 105 | end 106 | end 107 | } 108 | 109 | let (:inline) { form_class.new(OpenStruct.new(:song => Object.new)).song } 110 | 111 | it { _(inline.class.model_name).must_be_kind_of ActiveModel::Name } 112 | it { _(inline.class.model_name.to_s).must_equal "Hit" } 113 | end 114 | 115 | describe "inline without model" do 116 | let (:form_class) { 117 | Class.new(Reform::Form) do 118 | include Reform::Form::ActiveModel 119 | 120 | property :song do 121 | include Reform::Form::ActiveModel 122 | end 123 | 124 | collection :hits do 125 | include Reform::Form::ActiveModel 126 | end 127 | end 128 | } 129 | 130 | let (:form) { form_class.new(OpenStruct.new(:hits=>[OpenStruct.new], :song => OpenStruct.new)) } 131 | 132 | it { _(form.song.class.model_name).must_be_kind_of ActiveModel::Name } 133 | it { _(form.song.class.model_name.to_s).must_equal "Song" } 134 | it "singularizes collection name" do 135 | _(form.hits.first.class.model_name.to_s).must_equal "Hit" 136 | end 137 | end 138 | end 139 | end 140 | 141 | 142 | class ActiveModelWithCompositionTest < Minitest::Spec 143 | class HitForm < Reform::Form 144 | include Composition 145 | include Reform::Form::ActiveModel 146 | 147 | property :title, :on => :song 148 | properties :name, :genre, :on => :artist # we need to check both ::property and ::properties here! 149 | 150 | model :hit, :on => :song 151 | end 152 | 153 | let (:rio) { OpenStruct.new(:title => "Rio") } 154 | let (:duran) { OpenStruct.new } 155 | let (:form) { HitForm.new(:song => rio, :artist => duran) } 156 | 157 | describe "model accessors a la model#[:hit]" do 158 | it { _(form.model[:song]).must_equal rio } 159 | it { _(form.model[:artist]).must_equal duran } 160 | 161 | it "doesn't delegate when :on missing" do 162 | _(class SongOnlyForm < Reform::Form 163 | include Composition 164 | include Reform::Form::ActiveModel 165 | 166 | property :title, :on => :song 167 | 168 | model :song 169 | end.new(:song => rio, :artist => duran).model[:song]).must_equal rio 170 | end 171 | end 172 | 173 | 174 | it "provides ::model_name" do 175 | _(form.class.model_name).must_equal "Hit" 176 | end 177 | 178 | it "provides #persisted?" do 179 | _(HitForm.new(:song => OpenStruct.new.instance_eval { def persisted?; "yo!"; end; self }, :artist => OpenStruct.new).persisted?).must_equal "yo!" 180 | end 181 | 182 | it "provides #to_key" do 183 | _(HitForm.new(:song => OpenStruct.new.instance_eval { def to_key; "yo!"; end; self }, :artist => OpenStruct.new).to_key).must_equal "yo!" 184 | end 185 | 186 | it "provides #to_param" do 187 | _(HitForm.new(:song => OpenStruct.new.instance_eval { def to_param; "yo!"; end; self }, :artist => OpenStruct.new).to_param).must_equal "yo!" 188 | end 189 | 190 | it "provides #to_model" do 191 | form = HitForm.new(:song => OpenStruct.new, :artist => OpenStruct.new) 192 | _(form.to_model).must_equal form 193 | end 194 | 195 | it "works with any order of ::model and ::property" do 196 | class AnotherForm < Reform::Form 197 | include Composition 198 | include Reform::Form::ActiveModel 199 | 200 | model :song, :on => :song 201 | property :title, :on => :song 202 | end 203 | 204 | 205 | _(AnotherForm.new(:song => rio).model[:song]).must_equal rio 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /test/unique_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | require "reform/form/orm" 4 | require "reform/form/validation/unique_validator.rb" 5 | require "reform/form/active_record" 6 | 7 | class UniquenessValidatorOnCreateTest < Minitest::Spec 8 | class SongForm < Reform::Form 9 | include ActiveRecord 10 | property :title 11 | validates :title, unique: true 12 | end 13 | 14 | it do 15 | Song.delete_all 16 | 17 | form = SongForm.new(Song.new) 18 | _(form.validate("title" => "How Many Tears")).must_equal true 19 | form.save 20 | 21 | form = SongForm.new(Song.new) 22 | _(form.validate("title" => "How Many Tears")).must_equal false 23 | _(form.errors.messages).must_equal ({:title=>["has already been taken"]}) 24 | end 25 | end 26 | 27 | class UniquenessValidatorOnCreateCaseInsensitiveTest < Minitest::Spec 28 | class SongForm < Reform::Form 29 | include ActiveRecord 30 | property :title 31 | validates :title, unique: { case_sensitive: false } 32 | end 33 | 34 | it do 35 | Song.delete_all 36 | 37 | form = SongForm.new(Song.new) 38 | _(form.validate("title" => "How Many Tears")).must_equal true 39 | form.save 40 | 41 | form = SongForm.new(Song.new) 42 | _(form.validate("title" => "how many tears")).must_equal false 43 | _(form.errors.to_s).must_equal "{:title=>[\"has already been taken\"]}" 44 | end 45 | 46 | it do 47 | Song.delete_all 48 | 49 | form = SongForm.new(Song.new) 50 | _(form.validate({})).must_equal true 51 | end 52 | end 53 | 54 | class UniquenessValidatorOnUpdateTest < Minitest::Spec 55 | class SongForm < Reform::Form 56 | include ActiveRecord 57 | property :title 58 | validates :title, unique: true 59 | end 60 | 61 | it do 62 | Song.delete_all 63 | @song = Song.create(title: "How Many Tears") 64 | 65 | form = SongForm.new(@song) 66 | _(form.validate("title" => "How Many Tears")).must_equal true 67 | form.save 68 | 69 | form = SongForm.new(@song) 70 | _(form.validate("title" => "How Many Tears")).must_equal true 71 | end 72 | end 73 | 74 | class UniquenessValidatorOnUpdateWithDuplicateTest < Minitest::Spec 75 | class SongForm < Reform::Form 76 | include ActiveRecord 77 | property :title 78 | validates :title, unique: true 79 | end 80 | 81 | it do 82 | Song.delete_all 83 | 84 | song1 = Song.create(title: "How Many Tears") 85 | song2 = Song.create(title: "How Many Tears 2") 86 | 87 | form = SongForm.new(song1) 88 | _(form.validate("title" => "How Many Tears 2")).must_equal false 89 | _(form.errors.to_s).must_equal "{:title=>[\"has already been taken\"]}" 90 | end 91 | end 92 | 93 | class UniquenessValidatorWithFromPropertyTest < Minitest::Spec 94 | class SongForm < Reform::Form 95 | include ActiveRecord 96 | property :name, from: :title 97 | validates :name, unique: true 98 | end 99 | 100 | it do 101 | Song.delete_all 102 | 103 | form = SongForm.new(Song.new) 104 | _(form.validate("name" => "How Many Tears")).must_equal true 105 | form.save 106 | 107 | form = SongForm.new(Song.new) 108 | _(form.validate("name" => "How Many Tears")).must_equal false 109 | _(form.errors.to_s).must_equal "{:name=>[\"has already been taken\"]}" 110 | end 111 | end 112 | 113 | class UniqueWithCompositionTest < Minitest::Spec 114 | class SongForm < Reform::Form 115 | include ActiveRecord 116 | include Composition 117 | 118 | property :title, on: :song 119 | validates :title, unique: true 120 | end 121 | 122 | it do 123 | Song.delete_all 124 | 125 | form = SongForm.new(song: Song.new) 126 | _(form.validate("title" => "How Many Tears")).must_equal true 127 | form.save 128 | end 129 | end 130 | 131 | 132 | class UniqueValidatorWithScopeTest < Minitest::Spec 133 | class SongForm < Reform::Form 134 | include ActiveRecord 135 | property :album_id 136 | property :title 137 | validates :title, unique: { scope: :album_id } 138 | end 139 | 140 | it do 141 | Song.delete_all 142 | 143 | album = Album.new 144 | album.save 145 | 146 | form = SongForm.new(Song.new) 147 | _(form.validate(album_id: album.id, title: 'How Many Tears')).must_equal true 148 | form.save 149 | 150 | form = SongForm.new(Song.new) 151 | _(form.validate(album_id: album.id, title: 'How Many Tears')).must_equal false 152 | _(form.errors.messages).must_equal({:title=>["has already been taken"]}) 153 | 154 | album = Album.new 155 | album.save 156 | 157 | form = SongForm.new(Song.new) 158 | _(form.validate(album_id: album.id, title: 'How Many Tears')).must_equal true 159 | end 160 | end 161 | 162 | class UniqueValidatorWithScopeAndCaseInsensitiveTest < Minitest::Spec 163 | class SongForm < Reform::Form 164 | include ActiveRecord 165 | property :album_id 166 | property :title 167 | validates :title, unique: { scope: :album_id, case_sensitive: false } 168 | end 169 | 170 | it do 171 | Song.delete_all 172 | 173 | album = Album.new 174 | album.save 175 | 176 | form = SongForm.new(Song.new) 177 | _(form.validate(album_id: album.id, title: 'How Many Tears')).must_equal true 178 | form.save 179 | 180 | form = SongForm.new(Song.new) 181 | _(form.validate(album_id: album.id, title: 'how many tears')).must_equal false 182 | _(form.errors.to_s).must_equal "{:title=>[\"has already been taken\"]}" 183 | 184 | album = Album.new 185 | album.save 186 | 187 | form = SongForm.new(Song.new) 188 | _(form.validate(album_id: album.id, title: 'how many tears')).must_equal true 189 | end 190 | end 191 | 192 | class UniqueValidatorWithScopeArrayTest < Minitest::Spec 193 | class SongForm < Reform::Form 194 | include ActiveRecord 195 | property :album_id 196 | property :artist_id 197 | property :title 198 | validates :title, unique: { scope: [:album_id, :artist_id] } 199 | end 200 | 201 | it do 202 | Song.delete_all 203 | 204 | album1 = Album.new 205 | album1.save 206 | 207 | artist1 = Artist.new 208 | artist1.save 209 | 210 | form = SongForm.new(Song.new) 211 | _(form.validate(album_id: album1.id, artist_id: artist1.id, title: 'How Many Tears')).must_equal true 212 | form.save 213 | 214 | form = SongForm.new(Song.new) 215 | _(form.validate(album_id: album1.id, artist_id: artist1.id, title: 'How Many Tears')).must_equal false 216 | _(form.errors.messages).must_equal({:title=>["has already been taken"]}) 217 | 218 | album2 = Album.new 219 | album2.save 220 | 221 | form = SongForm.new(Song.new) 222 | _(form.validate(album_id: album2.id, artist_id: artist1.id, title: 'How Many Tears')).must_equal true 223 | 224 | artist2 = Artist.new 225 | artist2.save 226 | 227 | form = SongForm.new(Song.new) 228 | _(form.validate(album_id: album1.id, artist_id: artist2.id, title: 'How Many Tears')).must_equal true 229 | end 230 | end 231 | 232 | class UniqueValidatorWithConditions < Minitest::Spec 233 | class SongForm < Reform::Form 234 | include ActiveRecord 235 | property :title 236 | validates :title, unique: { conditions: -> { where(archived_at: nil) } } 237 | end 238 | 239 | it do 240 | Song.delete_all 241 | 242 | form = SongForm.new(Song.new) 243 | _(form.validate(title: 'How Many Tears')).must_equal true 244 | form.save 245 | 246 | form = SongForm.new(Song.new) 247 | _(form.validate(title: 'How Many Tears')).must_equal false 248 | _(form.errors.messages).must_equal({:title=>["has already been taken"]}) 249 | 250 | song = Song.last 251 | song.update!(archived_at: Time.now) 252 | 253 | form = SongForm.new(Song.new) 254 | _(form.validate(title: 'How Many Tears')).must_equal true 255 | form.save 256 | end 257 | end 258 | 259 | class UniqueValidatorWithConditionsWithRecord < Minitest::Spec 260 | class SongForm < Reform::Form 261 | include ActiveRecord 262 | property :title 263 | property :release_date 264 | validates :title, unique: { 265 | conditions: ->(form) { 266 | published_at = form.release_date 267 | where(release_date: published_at.beginning_of_month..published_at.end_of_month) 268 | } 269 | } 270 | end 271 | 272 | it do 273 | Song.delete_all 274 | 275 | today = Date.today 276 | form = SongForm.new(Song.new) 277 | _(form.validate(title: 'How Many Tears', release_date: today)).must_equal true 278 | form.save 279 | 280 | form = SongForm.new(Song.new) 281 | _(form.validate(title: 'How Many Tears', release_date: today)).must_equal false 282 | _(form.errors.messages).must_equal({:title=>["has already been taken"]}) 283 | 284 | form = SongForm.new(Song.new) 285 | _(form.validate(title: 'How Many Tears', release_date: today.next_month)).must_equal true 286 | form.save 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /test/active_record_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'reform/active_record' 3 | 4 | # ActiveRecord::Schema.define do 5 | # create_table :artists do |table| 6 | # table.column :name, :string 7 | # table.timestamps 8 | # end 9 | # create_table :songs do |table| 10 | # table.column :title, :string 11 | # table.column :artist_id, :integer 12 | # table.column :album_id, :integer 13 | # table.timestamps 14 | # end 15 | # create_table :albums do |table| 16 | # table.column :title, :string 17 | # table.timestamps 18 | # end 19 | # end 20 | # Artist.new(:name => "Racer X").save 21 | 22 | class ActiveRecordTest < Minitest::Spec 23 | class SongForm < Reform::Form 24 | feature Reform::Form::ActiveModel::Validations 25 | 26 | include Reform::Form::ActiveRecord 27 | model :song 28 | 29 | property :title 30 | property :created_at 31 | 32 | validates_uniqueness_of :title, scope: [:album_id, :artist_id] 33 | validates :created_at, :presence => true # have another property to test if we mix up. 34 | 35 | property :artist do 36 | property :name 37 | validates_uniqueness_of :name # this currently also tests if Form::AR is included as a feature. 38 | end 39 | end 40 | 41 | let(:album) { Album.create(:title => "Damnation") } 42 | let(:artist) { Artist.create(:name => "Opeth") } 43 | let(:form) { SongForm.new(Song.new(:artist => Artist.new)) } 44 | 45 | it { _(form.class.i18n_scope).must_equal :activerecord } 46 | 47 | # uniqueness 48 | it "has no errors on title when title is unique for the same artist and album" do 49 | form.validate("title" => "The Gargoyle", "artist_id" => artist.id, "album" => album.id, "created_at" => "November 6, 1966") 50 | assert_empty form.errors[:title] 51 | end 52 | 53 | it "has errors on title when title is taken for the same artist and album" do 54 | skip "replace ActiveModel::Validations with our own, working and reusable gem." 55 | Song.create(title: "Windowpane", artist_id: artist.id, album_id: album.id) 56 | form.validate("title" => "Windowpane", "artist_id" => artist.id, "album" => album) 57 | refute_empty form.errors[:title] 58 | end 59 | 60 | # nested object taken. 61 | it "is valid when artist name is unique" do 62 | _(form.validate("artist" => {"name" => "Paul Gilbert"}, "title" => "The Gargoyle", "created_at" => "November 6, 1966")).must_equal true 63 | end 64 | 65 | it "is invalid and shows error when taken" do 66 | Song.delete_all 67 | Artist.create(:name => "Racer X") 68 | 69 | _(form.validate("artist" => {"name" => "Racer X"}, "title" => "Ghost Inside My Skin")).must_equal false 70 | _(form.errors.messages).must_equal({:"artist.name"=>["has already been taken"], :created_at => ["can't be blank"]}) 71 | end 72 | 73 | it "works with Composition" do 74 | form = Class.new(Reform::Form) do 75 | include Reform::Form::ActiveRecord 76 | include Reform::Form::Composition 77 | 78 | property :name, :on => :artist 79 | validates_uniqueness_of :name 80 | end.new(:artist => Artist.new) 81 | 82 | Artist.create(:name => "Bad Religion") 83 | _(form.validate("name" => "Bad Religion")).must_equal false 84 | end 85 | 86 | describe "#save" do 87 | # TODO: test 1-n? 88 | it "calls model.save" do 89 | Artist.delete_all 90 | form.validate("artist" => {"name" => "Bad Religion"}, "title" => "Ghost Inside My Skin") 91 | form.save 92 | _(Artist.where(:name => "Bad Religion").size).must_equal 1 93 | end 94 | 95 | it "doesn't call model.save when block is given" do 96 | Artist.delete_all 97 | form.validate("name" => "Bad Religion") 98 | form.save {} 99 | _(Artist.where(:name => "Bad Religion").size).must_equal 0 100 | end 101 | 102 | it "can access block params using string or hash key" do 103 | Artist.delete_all 104 | form.validate("artist" => {"name" => "Paul Gilbert"}, "title" => "The Gargoyle", "created_at" => "November 6, 1966") 105 | form.save do |params| 106 | _(params[:title]).must_equal 'The Gargoyle' 107 | _(params['title']).must_equal 'The Gargoyle' 108 | end 109 | end 110 | end 111 | end 112 | 113 | 114 | class PopulateWithActiveRecordTest < Minitest::Spec 115 | class AlbumForm < Reform::Form 116 | 117 | property :title 118 | 119 | collection :songs, :populate_if_empty => Song do 120 | property :title 121 | end 122 | end 123 | 124 | let (:album) { Album.new(:songs => []) } 125 | it do 126 | form = AlbumForm.new(album) 127 | 128 | form.validate("songs" => [{"title" => "Straight From The Jacket"}]) 129 | 130 | # form populated. 131 | _(form.songs.size).must_equal 1 132 | _(form.songs[0].model).must_be_kind_of Song 133 | 134 | # model NOT populated. 135 | _(album.songs).must_equal [] 136 | 137 | 138 | form.sync 139 | 140 | # form populated. 141 | _(form.songs.size).must_equal 1 142 | _(form.songs[0].model).must_be_kind_of Song 143 | 144 | # model also populated. 145 | song = album.songs[0] 146 | _(album.songs).must_equal [song] 147 | _(song.title).must_equal "Straight From The Jacket" 148 | 149 | 150 | if ActiveRecord::VERSION::STRING !~ /^3.0/ 151 | # saving saves association. 152 | form.save 153 | 154 | album.reload 155 | song = album.songs[0] 156 | _(album.songs).must_equal [song] 157 | _(song.title).must_equal "Straight From The Jacket" 158 | end 159 | end 160 | 161 | 162 | describe "modifying 1., adding 2." do 163 | let (:song) { Song.new(:title => "Part 2") } 164 | let (:album) { Album.create.tap { |a| a.songs << song } } 165 | 166 | it do 167 | form = AlbumForm.new(album) 168 | 169 | id = album.songs[0].id 170 | assert id > 0 171 | 172 | form.validate("songs" => [{"title" => "Part Two"}, {"title" => "Check For A Pulse"}]) 173 | 174 | # form populated. 175 | _(form.songs.size).must_equal 2 176 | _(form.songs[0].model).must_be_kind_of Song 177 | _(form.songs[1].model).must_be_kind_of Song 178 | 179 | # model NOT populated. 180 | _(album.songs).must_equal [song] 181 | 182 | 183 | form.sync 184 | 185 | # form populated. 186 | _(form.songs.size).must_equal 2 187 | 188 | # model also populated. 189 | _(album.songs.size).must_equal 2 190 | 191 | # corrected title 192 | _(album.songs[0].title).must_equal "Part Two" 193 | # ..but same song. 194 | _(album.songs[0].id).must_equal id 195 | 196 | # and a new song. 197 | _(album.songs[1].title).must_equal "Check For A Pulse" 198 | _(album.songs[1].persisted?).must_equal true # TODO: with << strategy, this shouldn't be saved. 199 | end 200 | 201 | describe 'using nested_models_attributes to modify nested collection' do 202 | class ActiveModelAlbumForm < Reform::Form 203 | include Reform::Form::ActiveModel 204 | include Reform::Form::ActiveModel::FormBuilderMethods 205 | 206 | property :title 207 | 208 | collection :songs, :populate_if_empty => Song do 209 | property :title 210 | end 211 | end 212 | 213 | let (:album) { Album.create(:title => 'Greatest Hits') } 214 | let (:form) { ActiveModelAlbumForm.new(album) } 215 | 216 | it "xxx"do 217 | form.validate('songs_attributes' => {'0' => {'title' => 'Tango'}}) 218 | 219 | # form populated. 220 | _(form.songs.size).must_equal 1 221 | _(form.songs[0].model).must_be_kind_of Song 222 | _(form.songs[0].title).must_equal 'Tango' 223 | 224 | # model NOT populated. 225 | _(album.songs).must_equal [] 226 | 227 | form.save 228 | 229 | # nested model persisted. 230 | first_song = album.songs[0] 231 | assert first_song.id > 0 232 | 233 | # form populated. 234 | _(form.songs.size).must_equal 1 235 | 236 | # model also populated. 237 | _(album.songs.size).must_equal 1 238 | _(album.songs[0].title).must_equal 'Tango' 239 | 240 | # DISCUSS: IfEmpty uses twin.original[index] for syncing. in our case, this is empty, so it will add Tango again. 241 | form = ActiveModelAlbumForm.new(album) 242 | form.validate('songs_attributes' => {'0' => {'id' => first_song.id, 'title' => 'Tango nuevo'}, '1' => {'title' => 'Waltz'}}) 243 | 244 | # form populated. 245 | _(form.songs.size).must_equal 2 246 | _(form.songs[0].model).must_be_kind_of Song 247 | _(form.songs[1].model).must_be_kind_of Song 248 | _(form.songs[0].title).must_equal 'Tango nuevo' 249 | _(form.songs[1].title).must_equal 'Waltz' 250 | 251 | # model NOT populated. 252 | _(album.songs.size).must_equal 1 253 | _(album.songs[0].title).must_equal 'Tango' 254 | 255 | form.save 256 | 257 | # form populated. 258 | _(form.songs.size).must_equal 2 259 | 260 | # model also populated. 261 | _(album.songs.size).must_equal 2 262 | _(album.songs[0].id).must_equal first_song.id 263 | _(album.songs[0].persisted?).must_equal true 264 | _(album.songs[1].persisted?).must_equal true 265 | _(album.songs[0].title).must_equal 'Tango nuevo' 266 | _(album.songs[1].title).must_equal 'Waltz' 267 | end 268 | end 269 | end 270 | end 271 | 272 | 273 | 274 | -------------------------------------------------------------------------------- /lib/reform/form/active_model/validations.rb: -------------------------------------------------------------------------------- 1 | require "active_model" 2 | require "reform/form/active_model" 3 | require "uber/delegates" 4 | 5 | module Reform 6 | module Form::ActiveModel 7 | # AM::Validations for your form. 8 | # Provides ::validates, ::validate, #validate, and #valid?. 9 | # 10 | # Most of this file contains unnecessary wiring to make ActiveModel's error message magic work. 11 | # Since Rails still thinks it's a good idea to do things like object.class.human_attribute_name, 12 | # we have some hacks in here to provide that. If it doesn't work for you, don't blame us. 13 | module Validations 14 | def self.included(includer) 15 | includer.instance_eval do 16 | include Reform::Form::ActiveModel 17 | 18 | class << self 19 | extend Uber::Delegates 20 | # # Hooray! Delegate translation back to Reform's Validator class which contains AM::Validations. 21 | delegates :active_model_really_sucks, :human_attribute_name, :lookup_ancestors, :i18n_scope # Rails 3.1. 22 | 23 | def validation_group_class 24 | Group 25 | end 26 | 27 | # this is to allow calls like Form::human_attribute_name (note that this is on the CLASS level) to be resolved. 28 | # those calls happen when adding errors in a custom validation method, which is defined on the form (as an instance method). 29 | def active_model_really_sucks 30 | Class.new(Validator).tap do |v| 31 | v.model_name = model_name 32 | end 33 | end 34 | end 35 | end # ::included 36 | end 37 | 38 | # The concept of "composition" has still not arrived in Rails core and they rely on 400 methods being 39 | # available in one object. This is why we need to provide parts of the I18N API in the form. 40 | def read_attribute_for_validation(name) 41 | send(name) 42 | end 43 | 44 | def initialize(*) 45 | super 46 | @amv_errors = ActiveModel::Errors.new(self) 47 | end 48 | 49 | # Problem with this method is, it's being used in two completely different contexts: Once to add errors in validations, 50 | # and second to expose errors for presentation. 51 | def errors(*args) 52 | @amv_errors 53 | end 54 | 55 | def custom_errors 56 | # required to keep update the ActiveModel::Errors#details used to test for 57 | # added errors ActiveModel::Errors#added? and needs to be inside this block! 58 | super.each do |custom_error| 59 | errors = custom_error.errors 60 | # CustomError build always the errors with an hash where the value is an array 61 | errors.values.first.each do |value| 62 | @amv_errors.add(errors.keys.first, value) 63 | end 64 | end 65 | end 66 | 67 | def validate!(params, pointers=[]) 68 | @amv_errors = ActiveModel::Errors.new(self) 69 | 70 | super.tap do 71 | # @fran: super ugly hack thanks to the shit architecture of AMV. let's drop it in 3.0 and move on! 72 | all_errors = @result.to_results 73 | nested_errors = @result.instance_variable_get(:@failure) 74 | 75 | @result = Reform::Contract::Result.new(all_errors, [nested_errors].compact) 76 | 77 | @amv_errors = Result::ResultErrors.new(@result, self, @result.success?, @amv_errors) 78 | end 79 | @result 80 | end 81 | 82 | class Group 83 | def initialize(*) 84 | @validations = Class.new(Reform::Form::ActiveModel::Validations::Validator) 85 | end 86 | 87 | extend Uber::Delegates 88 | delegates :@validations, :validates, :validate, :validates_with, :validate_with, :validates_each 89 | 90 | def call(form) 91 | validator = @validations.new(form) 92 | validator.instance_variable_set(:@errors, form.errors) 93 | success = validator.valid? # run the validations. 94 | 95 | Result.new(success, validator.errors.messages) 96 | end 97 | end 98 | 99 | # The idea here to mimic Dry.RB's Result API. 100 | class Result < Hash # FIXME; should this be AMV::Errors? 101 | def initialize(success, hash) 102 | super() 103 | @success = success 104 | hash.each { |k,v| self[k] = v } 105 | end 106 | 107 | def success? 108 | @success 109 | end 110 | 111 | def failure? 112 | ! success? 113 | end 114 | 115 | def messages 116 | self 117 | end 118 | 119 | # DISCUSS @FRAN: not sure this is 100% compatible with AMV::Errors? 120 | def errors 121 | self 122 | end 123 | 124 | class ResultErrors < ::Reform::Contract::Result::Errors # to expose via #errors. i hate it. 125 | def initialize(a, b, success, amv_errors) 126 | super(a, b) 127 | @success = success 128 | @amv_errors = amv_errors 129 | end 130 | 131 | def empty? 132 | @success 133 | end 134 | 135 | def [](k) 136 | super(k.to_sym) || [] 137 | end 138 | 139 | # rails expects this to return a stringified hash of the messages 140 | def to_s 141 | messages.to_s 142 | end 143 | 144 | def add(key, error_text, **error_options) 145 | # use rails magic to get the correct error_text and make sure we still update details and fields 146 | error = @amv_errors.add(key, error_text, **error_options) 147 | error = [error.message] unless error.is_a?(Array) 148 | 149 | # using error_text instead of text to either keep the symbol which will be 150 | # magically replaced with the translate or directly the string - this is also 151 | # required otherwise in the custom_errors method we will add the actual message in the 152 | # ActiveModel::Errors#details which is not correct if a symbol was passed here 153 | Reform::Contract::CustomError.new(key, error_text, @result.to_results) 154 | 155 | # but since messages method is actually already defined in `Reform::Contract::Result::Errors 156 | # we need to update the @dotted_errors instance variable to add or merge a new error 157 | @dotted_errors.key?(key) ? @dotted_errors[key] |= error : @dotted_errors[key] = error 158 | instance_variable_set(:@dotted_errors, @dotted_errors) 159 | end 160 | 161 | def method_missing(m, *args, &block) 162 | @amv_errors.send(m, *args, &block) # send all methods to the AMV errors, even privates. 163 | end 164 | ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) 165 | 166 | def respond_to?(method, include_all = false) 167 | @amv_errors.respond_to?(method, include_all) ? true : super 168 | end 169 | 170 | def full_messages 171 | base_errors = @amv_errors.full_messages 172 | form_fields = @amv_errors.instance_variable_get(:@base).instance_variable_get(:@fields) 173 | nested_errors = full_messages_for_nested_fields(form_fields) 174 | 175 | [base_errors, nested_errors].flatten.compact 176 | end 177 | 178 | private 179 | 180 | def full_messages_for_nested_fields(form_fields) 181 | form_fields 182 | .to_a 183 | .reject { |field| field[0] == "parent" } 184 | .map { |field| full_messages_for_twin(field[1]) } 185 | end 186 | 187 | def full_messages_for_twin(object) 188 | return get_collection_errors(object) if object.is_a? Disposable::Twin::Collection 189 | return get_amv_errors(object) if object.is_a? Disposable::Twin 190 | 191 | nil 192 | end 193 | 194 | def get_collection_errors(twin_collection) 195 | twin_collection.map { |twin| get_amv_errors(twin) } 196 | end 197 | 198 | def get_amv_errors(object) 199 | object.instance_variable_get(:@amv_errors).full_messages 200 | end 201 | end 202 | end 203 | 204 | # Validator is the validatable object. On the class level, we define validations, 205 | # on instance, it exposes #valid?. 206 | require "delegate" 207 | class Validator < SimpleDelegator 208 | # current i18n scope: :activemodel. 209 | include ActiveModel::Validations 210 | 211 | class << self 212 | def model_name 213 | @_active_model_sucks ||= ActiveModel::Name.new(Reform::Form, nil, "Reform::Form") 214 | end 215 | 216 | def model_name=(name) 217 | @_active_model_sucks = name 218 | end 219 | 220 | def validates(*args, &block) 221 | super(*Declarative::DeepDup.(args), &block) 222 | end 223 | 224 | # Prevent AM:V from mutating the validator class 225 | def attr_reader(*) 226 | end 227 | 228 | def attr_writer(*) 229 | end 230 | end 231 | 232 | def initialize(form) 233 | super(form) 234 | self.class.model_name = form.model_name 235 | end 236 | 237 | def method_missing(m, *args, &block) 238 | __getobj__.send(m, *args, &block) # send all methods to the form, even privates. 239 | end 240 | ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) 241 | end 242 | end 243 | 244 | 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /test/mongoid_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | return unless defined?(::Mongoid) 4 | 5 | Mongoid.configure do |config| 6 | config.connect_to("reform-mongoid-test") 7 | end 8 | require 'reform/mongoid' 9 | 10 | module MongoidTests 11 | class Disc 12 | include Mongoid::Document 13 | field :title, type: String 14 | has_many :tunes, inverse_of: :disc 15 | has_and_belongs_to_many :musicians 16 | end 17 | 18 | class Musician 19 | include Mongoid::Document 20 | field :name, type: String 21 | end 22 | 23 | class Tune 24 | include Mongoid::Document 25 | include Mongoid::Timestamps 26 | field :title, type: String 27 | belongs_to :disc, inverse_of: :tunes 28 | belongs_to :musician 29 | end 30 | 31 | class MongoidTest < Minitest::Spec 32 | class TuneForm < Reform::Form 33 | include Reform::Form::Mongoid 34 | model :tune 35 | 36 | property :title 37 | property :created_at 38 | 39 | validates_uniqueness_of :title, scope: [:disc_id, :musician_id] 40 | validates :created_at, :presence => true # have another property to test if we mix up. 41 | 42 | property :musician do 43 | property :name 44 | validates_uniqueness_of :name # this currently also tests if Form::AR is included as a feature. 45 | end 46 | end 47 | 48 | let(:disc) {Disc.create(:title => "Damnation")} 49 | let(:musician) {Musician.create(:name => "Opeth")} 50 | let(:form) {TuneForm.new(Tune.new(:musician => Musician.new))} 51 | 52 | # it { _(form.class.i18n_scope).must_equal :mongoid } 53 | 54 | it "allows accessing the database" do 55 | end 56 | 57 | # uniqueness 58 | it "has no errors on title when title is unique for the same musician and disc" do 59 | form.validate("title" => "The Gargoyle", "musician_id" => musician.id, "disc" => disc.id, "created_at" => "November 6, 1966") 60 | assert_empty form.errors[:title] 61 | end 62 | 63 | it "has errors on title when title is taken for the same musician and disc" do 64 | skip "replace ActiveModel::Validations with our own, working and reusable gem." 65 | Tune.create(title: "Windowpane", musician_id: musician.id, disc_id: disc.id) 66 | form.validate("title" => "Windowpane", "musician_id" => musician.id, "disc" => disc) 67 | refute_empty form.errors[:title] 68 | end 69 | 70 | # nested object taken. 71 | it "is valid when musician name is unique" do 72 | _(form.validate("musician" => {"name" => "Paul Gilbert"}, "title" => "The Gargoyle", "created_at" => "November 6, 1966")).must_equal true 73 | end 74 | 75 | it "is invalid and shows error when taken" do 76 | Tune.delete_all 77 | Musician.create(:name => "Racer X") 78 | 79 | _(form.validate("musician" => {"name" => "Racer X"}, "title" => "Ghost Inside My Skin")).must_equal false 80 | if Gem::Version.new(ActiveModel::VERSION::STRING) <= Gem::Version.new('6.0.0') 81 | _(form.errors.messages.sort).must_equal({:"musician.name" => ["is already taken"], :created_at => ["can't be blank"]}.sort) 82 | else 83 | _(form.errors.messages.sort).must_equal({:"musician.name" => ["has already been taken"], :created_at => ["can't be blank"]}.sort) 84 | end 85 | end 86 | 87 | it "works with Composition" do 88 | form = Class.new(Reform::Form) do 89 | include Reform::Form::Mongoid 90 | include Reform::Form::Composition 91 | 92 | property :name, :on => :musician 93 | validates_uniqueness_of :name 94 | end.new(:musician => Musician.new) 95 | 96 | Musician.create(:name => "Bad Religion") 97 | _(form.validate("name" => "Bad Religion")).must_equal false 98 | end 99 | 100 | describe "#save" do 101 | # TODO: test 1-n? 102 | it "calls model.save" do 103 | Musician.delete_all 104 | form.validate("musician" => {"name" => "Bad Religion"}, "title" => "Ghost Inside My Skin") 105 | form.save 106 | _(Musician.where(:name => "Bad Religion").size).must_equal 1 107 | end 108 | 109 | it "doesn't call model.save when block is given" do 110 | Musician.delete_all 111 | form.validate("name" => "Bad Religion") 112 | form.save {} 113 | _(Musician.where(:name => "Bad Religion").size).must_equal 0 114 | end 115 | end 116 | end 117 | 118 | class PopulateWithMongoidTest < Minitest::Spec 119 | class DiscForm < Reform::Form 120 | 121 | property :title 122 | 123 | collection :tunes, :populate_if_empty => Tune do 124 | property :title 125 | end 126 | end 127 | 128 | let (:disc) {Disc.new(:tunes => [])} 129 | it do 130 | form = DiscForm.new(disc) 131 | 132 | form.validate("tunes" => [{"title" => "Straight From The Jacket"}]) 133 | 134 | # form populated. 135 | _(form.tunes.size).must_equal 1 136 | _(form.tunes[0].model).must_be_kind_of Tune 137 | 138 | # model NOT populated. 139 | _(disc.tunes).must_equal [] 140 | 141 | 142 | form.sync 143 | 144 | # form populated. 145 | _(form.tunes.size).must_equal 1 146 | _(form.tunes[0].model).must_be_kind_of Tune 147 | 148 | # model also populated. 149 | tune = disc.tunes[0] 150 | _(disc.tunes).must_equal [tune] 151 | _(tune.title).must_equal "Straight From The Jacket" 152 | 153 | 154 | # if ActiveRecord::VERSION::STRING !~ /^3.0/ 155 | # # saving saves association. 156 | # form.save 157 | # 158 | # disc.reload 159 | # tune = disc.tunes[0] 160 | # _(disc.tunes).must_equal [tune] 161 | # _(tune.title).must_equal "Straight From The Jacket" 162 | # end 163 | end 164 | 165 | 166 | describe "modifying 1., adding 2." do 167 | let (:tune) {Tune.new(:title => "Part 2")} 168 | let (:disc) {Disc.create.tap {|a| a.tunes << tune}} 169 | 170 | it do 171 | skip('fails') 172 | form = DiscForm.new(disc) 173 | 174 | id = disc.tunes[0].id 175 | _(disc.tunes[0].persisted?).must_equal true 176 | assert id.to_s.size > 0 177 | 178 | form.validate("tunes" => [{"title" => "Part Two"}, {"title" => "Check For A Pulse"}]) 179 | 180 | # form populated. 181 | _(form.tunes.size).must_equal 2 182 | form.tunes[0].model.must_be_kind_of Tune 183 | form.tunes[1].model.must_be_kind_of Tune 184 | 185 | # model NOT populated. 186 | _(disc.tunes).must_equal [tune] 187 | 188 | 189 | form.sync 190 | 191 | # form populated. 192 | _(form.tunes.size).must_equal 2 193 | 194 | # model also populated. 195 | _(disc.tunes.size).must_equal 2 196 | 197 | # corrected title 198 | _(disc.tunes[0].title).must_equal "Part Two" 199 | # ..but same tune. 200 | _(disc.tunes[0].id).must_equal id 201 | 202 | # and a new tune. 203 | _(disc.tunes[1].title).must_equal "Check For A Pulse" 204 | _(disc.tunes[1].persisted?).must_equal true # TODO: with << strategy, this shouldn't be saved. 205 | end 206 | 207 | describe 'using nested_models_attributes to modify nested collection' do 208 | class ActiveModelDiscForm < Reform::Form 209 | include Reform::Form::ActiveModel 210 | include Reform::Form::ActiveModel::FormBuilderMethods 211 | 212 | property :title 213 | 214 | collection :tunes, :populate_if_empty => Tune do 215 | property :title 216 | end 217 | end 218 | 219 | let (:disc) {Disc.create(:title => 'Greatest Hits')} 220 | let (:form) {ActiveModelDiscForm.new(disc)} 221 | 222 | it do 223 | skip('fails') 224 | form.validate('tunes_attributes' => {'0' => {'title' => 'Tango'}}) 225 | 226 | # form populated. 227 | _(form.tunes.size).must_equal 1 228 | _(form.tunes[0].model).must_be_kind_of Tune 229 | _(form.tunes[0].title).must_equal 'Tango' 230 | 231 | # model NOT populated. 232 | _(disc.tunes).must_equal [] 233 | 234 | form.save 235 | 236 | # nested model persisted. 237 | first_tune = disc.tunes[0] 238 | _(first_tune.persisted?).must_equal true 239 | assert first_tune.id.to_s.size > 0 240 | 241 | # form populated. 242 | _(form.tunes.size).must_equal 1 243 | 244 | # model also populated. 245 | _(disc.tunes.size).must_equal 1 246 | _(disc.tunes[0].title).must_equal 'Tango' 247 | 248 | form = ActiveModelDiscForm.new(disc) 249 | form.validate('tunes_attributes' => {'0' => {'id' => first_tune.id, 'title' => 'Tango nuevo'}, '1' => {'title' => 'Waltz'}}) 250 | 251 | # form populated. 252 | _(form.tunes.size).must_equal 2 253 | form.tunes[0].model.must_be_kind_of Tune 254 | form.tunes[1].model.must_be_kind_of Tune 255 | _(form.tunes[0].title).must_equal 'Tango nuevo' 256 | _(form.tunes[1].title).must_equal 'Waltz' 257 | 258 | # model NOT populated. 259 | _(disc.tunes.size).must_equal 1 260 | _(disc.tunes[0].title).must_equal 'Tango' 261 | 262 | form.save 263 | 264 | # form populated. 265 | _(form.tunes.size).must_equal 2 266 | 267 | # model also populated. 268 | _(disc.tunes.size).must_equal 2 269 | _(disc.tunes[0].id).must_equal first_tune.id 270 | _(disc.tunes[0].persisted?).must_equal true 271 | _(disc.tunes[1].persisted?).must_equal true 272 | _(disc.tunes[0].title).must_equal 'Tango nuevo' 273 | _(disc.tunes[1].title).must_equal 'Waltz' 274 | end 275 | end 276 | end 277 | 278 | # it do 279 | # a=Disc.new 280 | # a.tunes << Tune.new(title: "Old What's His Name") # Tune does not get persisted. 281 | 282 | # a.tunes[1] = Tune.new(title: "Permanent Rust") 283 | 284 | # puts "@@@" 285 | # puts a.tunes.inspect 286 | 287 | # puts "---" 288 | # a.save 289 | # puts a.tunes.inspect 290 | 291 | # b = a.tunes.first 292 | 293 | # a.tunes = [Tune.new(title:"Biomag")] 294 | # puts "\\\\" 295 | # a.save 296 | # a.reload 297 | # puts a.tunes.inspect 298 | 299 | # b.reload 300 | # puts "#{b.inspect}, #{b.persisted?}" 301 | 302 | 303 | # a.tunes = [a.tunes.first, Tune.new(title: "Count Down")] 304 | # b = a.tunes.first 305 | # puts ":::::" 306 | # a.save 307 | # a.reload 308 | # puts a.tunes.inspect 309 | 310 | # b.reload 311 | # puts "#{b.inspect}, #{b.persisted?}" 312 | # end 313 | end 314 | end 315 | -------------------------------------------------------------------------------- /test/activemodel_validation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveModelValidationTest < Minitest::Spec 4 | Session = Struct.new(:username, :email, :password, :confirm_password) 5 | Album = Struct.new(:name, :songs, :artist) 6 | Artist = Struct.new(:name) 7 | 8 | class SessionForm < Reform::Form 9 | include Reform::Form::ActiveModel::Validations 10 | 11 | property :username 12 | property :email 13 | property :password 14 | property :confirm_password 15 | 16 | validation name: :default do 17 | validates :username, presence: true 18 | validates :email, presence: true 19 | end 20 | 21 | validation name: :email, if: :default do 22 | # validate :email_ok? # FIXME: implement that. 23 | validates :email, length: {is: 3} 24 | end 25 | 26 | validation name: :nested, if: :default do 27 | validates :password, presence: true, length: {is: 1} 28 | end 29 | 30 | validation name: :confirm, if: :default, after: :email do 31 | validates :confirm_password, length: {is: 2} 32 | end 33 | end 34 | 35 | let (:form) { SessionForm.new(Session.new) } 36 | 37 | # valid. 38 | it do 39 | _(form.validate({username: "Helloween", email: "yep", password: "9", confirm_password:"dd"})).must_equal true 40 | _(form.errors.messages.inspect).must_equal "{}" 41 | end 42 | 43 | # invalid. 44 | it do 45 | _(form.validate({})).must_equal false 46 | _(form.errors.messages.inspect).must_equal "{:username=>[\"can't be blank\"], :email=>[\"can't be blank\"]}" 47 | _(form.errors[:username]).must_equal ["can't be blank"] 48 | _(form.errors['username']).must_equal ["can't be blank"] 49 | end 50 | 51 | # partially invalid. 52 | # 2nd group fails. 53 | let (:character) { self.class.rails_greater_4_1? ? :character : :characters} 54 | it do 55 | _(form.validate(username: "Helloween", email: "yo")).must_equal false 56 | _(form.errors.messages.inspect).must_equal "{:email=>[\"is the wrong length (should be 3 characters)\"], :confirm_password=>[\"is the wrong length (should be 2 characters)\"], :password=>[\"can't be blank\", \"is the wrong length (should be 1 #{character})\"]}" 57 | end 58 | # 3rd group fails. 59 | it do 60 | _(form.validate(username: "Helloween", email: "yo!")).must_equal false 61 | _(form.errors.messages.inspect).must_equal"{:confirm_password=>[\"is the wrong length (should be 2 characters)\"], :password=>[\"can't be blank\", \"is the wrong length (should be 1 #{character})\"]}" 62 | end 63 | # 4th group with after: fails. 64 | it do 65 | _(form.validate(username: "Helloween", email: "yo!", password: "1", confirm_password: "9")).must_equal false 66 | _(form.errors.messages.inspect).must_equal "{:confirm_password=>[\"is the wrong length (should be 2 characters)\"]}" 67 | end 68 | 69 | 70 | describe "implicit :default group" do 71 | # implicit :default group. 72 | class LoginForm < Reform::Form 73 | include Reform::Form::ActiveModel::Validations 74 | 75 | 76 | property :username 77 | property :email 78 | property :password 79 | property :confirm_password 80 | 81 | validates :username, presence: true 82 | validates :email, presence: true 83 | validates :password, presence: true 84 | 85 | validation name: :after_default, if: :default do 86 | validates :confirm_password, presence: true 87 | end 88 | end 89 | 90 | let (:form) { LoginForm.new(Session.new) } 91 | 92 | # valid. 93 | it do 94 | _(form.validate({username: "Helloween", email: "yep", password: "9", confirm_password: 9})).must_equal true 95 | _(form.errors.messages.inspect).must_equal "{}" 96 | end 97 | 98 | # invalid. 99 | it do 100 | _(form.validate({password: 9})).must_equal false 101 | _(form.errors.messages.inspect).must_equal "{:username=>[\"can't be blank\"], :email=>[\"can't be blank\"]}" 102 | end 103 | 104 | # partially invalid. 105 | # 2nd group fails. 106 | it do 107 | _(form.validate(password: 9)).must_equal false 108 | _(form.errors.messages.inspect).must_equal "{:username=>[\"can't be blank\"], :email=>[\"can't be blank\"]}" 109 | end 110 | end 111 | 112 | 113 | # describe "overwriting a group" do 114 | # class OverwritingForm < Reform::Form 115 | # include Reform::Form::ActiveModel::Validations 116 | 117 | # property :username 118 | # property :email 119 | 120 | # validation :email do 121 | # validates :email, presence: true # is not considered, but overwritten. 122 | # end 123 | 124 | # validation :email do # overwrites the above. 125 | # validates :username, presence: true 126 | # end 127 | # end 128 | 129 | # let (:form) { OverwritingForm.new(Session.new) } 130 | 131 | # # valid. 132 | # it do 133 | # _(form.validate({username: "Helloween"})).must_equal true 134 | # end 135 | 136 | # # invalid. 137 | # it do 138 | # _(form.validate({})).must_equal false 139 | # _(form.errors.messages.inspect).must_equal "{:username=>[\"username can't be blank\"]}" 140 | # end 141 | # end 142 | 143 | 144 | describe "inherit: true in same group" do 145 | class InheritSameGroupForm < Reform::Form 146 | include Reform::Form::ActiveModel::Validations 147 | 148 | property :username 149 | property :email 150 | 151 | validation name: :email do 152 | validates :email, presence: true 153 | end 154 | 155 | validation name: :email, inherit: true do # extends the above. 156 | validates :username, presence: true 157 | end 158 | end 159 | 160 | let (:form) { InheritSameGroupForm.new(Session.new) } 161 | 162 | # valid. 163 | it do 164 | _(form.validate({username: "Helloween", email: 9})).must_equal true 165 | end 166 | 167 | # invalid. 168 | it do 169 | _(form.validate({})).must_equal false 170 | _(form.errors.messages.inspect).must_equal "{:email=>[\"can't be blank\"], :username=>[\"can't be blank\"]}" 171 | 172 | if self.class.rails5? 173 | _(form.errors.details.inspect).must_equal "{:email=>[{:error=>:blank}], :username=>[{:error=>:blank}]}" 174 | end 175 | end 176 | end 177 | 178 | 179 | describe "if: with lambda" do 180 | class IfWithLambdaForm < Reform::Form 181 | include Reform::Form::ActiveModel::Validations 182 | 183 | property :username 184 | property :email 185 | property :password 186 | 187 | validation name: :email do 188 | validates :email, presence: true 189 | end 190 | 191 | # run this is :email group is true. 192 | validation name: :after_email, if: lambda { |results| results[:email].success? } do # extends the above. 193 | validates :username, presence: true 194 | end 195 | 196 | # block gets evaled in form instance context. 197 | validation name: :password, if: lambda { |results| email == "john@trb.org" } do 198 | validates :password, presence: true 199 | end 200 | end 201 | 202 | let (:form) { IfWithLambdaForm.new(Session.new) } 203 | 204 | # valid. 205 | it do 206 | _(form.validate({username: "Strung Out", email: 9})).must_equal true 207 | end 208 | 209 | # invalid. 210 | it do 211 | _(form.validate({email: 9})).must_equal false 212 | _(form.errors.messages.inspect).must_equal "{:username=>[\"can't be blank\"]}" 213 | if self.class.rails5? 214 | _(form.errors.details.inspect).must_equal "{:username=>[{:error=>:blank}]}" 215 | end 216 | 217 | end 218 | end 219 | 220 | 221 | # TODO: describe "multiple errors for property" do 222 | 223 | describe "::validate" do 224 | class ValidateForm < Reform::Form 225 | include Reform::Form::ActiveModel::Validations 226 | 227 | property :email 228 | property :username 229 | 230 | validates :username, presence: true 231 | validate :username_ok?#, context: :entity 232 | validate :username_yo? 233 | 234 | validates :email, presence: true 235 | validate :email_present? 236 | 237 | # this breaks as at the point of execution 'errors' doesn't exist... 238 | # Guessing it's unexceptable to introduce our own API.... 239 | # add_error(:key, val) 240 | def username_ok?#(value) 241 | errors.add(:username, "not ok") if username == "yo" 242 | end 243 | 244 | # depends on username_ok? result. this tests the same errors is used. 245 | def username_yo? 246 | errors.add(:username, "must be yo") if errors[:username].any? 247 | end 248 | 249 | def email_present? 250 | errors.add(:email, "fill it out!") if errors[:email].any? 251 | end 252 | end 253 | 254 | let (:form) { ValidateForm.new(Session.new) } 255 | 256 | # invalid. 257 | it "is invalid" do 258 | _(form.validate({username: "yo", email: nil})).must_equal false 259 | _(form.errors.messages).must_equal({:email=>["can't be blank", "fill it out!"], :username=>["not ok", "must be yo"]}) 260 | if self.class.rails5? 261 | _(form.errors.details.inspect).must_equal "{:username=>[{:error=>\"not ok\"}, {:error=>\"must be yo\"}], :email=>[{:error=>:blank}, {:error=>\"fill it out!\"}]}" 262 | end 263 | end 264 | 265 | # valid. 266 | it "is valid" do 267 | _(form.validate({ username: "not yo", email: "bla" })).must_equal true 268 | if self.class.rails_greater_6_0? 269 | _(form.errors.messages).must_equal({}) 270 | else 271 | _(form.errors.messages).must_equal({:username=>[], :email=>[]}) 272 | end 273 | if self.class.rails5? 274 | _(form.errors.details.inspect).must_equal "{}" 275 | end 276 | _(form.errors.empty?).must_equal true 277 | end 278 | 279 | it 'able to add errors' do 280 | _(form.validate(username: "yo", email: nil)).must_equal false 281 | _(form.errors.messages).must_equal(email: ["can't be blank", "fill it out!"], username: ["not ok", "must be yo"]) 282 | _(form.errors.details).must_equal(username: [{error: "not ok"}, {error: "must be yo"}], email: [{error: :blank}, {error: "fill it out!"}]) 283 | # add a new custom error 284 | form.errors.add(:policy, "error_text") 285 | _(form.errors.messages).must_equal(email: ["can't be blank", "fill it out!"], username: ["not ok", "must be yo"], policy: ["error_text"]) 286 | _(form.errors.details).must_equal( 287 | username: [{error: "not ok"}, {error: "must be yo"}], 288 | email: [{error: :blank}, {error: "fill it out!"}], 289 | policy: [error: "error_text"] 290 | ) 291 | # does not duplicate errors 292 | form.errors.add(:email, "fill it out!") 293 | _(form.errors.messages).must_equal(email: ["can't be blank", "fill it out!"], username: ["not ok", "must be yo"], policy: ["error_text"]) 294 | _(form.errors.details).must_equal( 295 | username: [{error: "not ok"}, {error: "must be yo"}], 296 | email: [{error: :blank}, {error: "fill it out!"}, {error: "fill it out!"}], 297 | policy: [error: "error_text"] 298 | ) 299 | # merge existing errors 300 | form.errors.add(:policy, "another error") 301 | _(form.errors.messages).must_equal(email: ["can't be blank", "fill it out!"], username: ["not ok", "must be yo"], policy: ["error_text", "another error"]) 302 | _(form.errors.details).must_equal( 303 | username: [{error: "not ok"}, {error: "must be yo"}], 304 | email: [{error: :blank}, {error: "fill it out!"}, {error: "fill it out!"}], 305 | policy: [{error: "error_text"}, {error: "another error"}] 306 | ) 307 | # keep added errors after valid? 308 | form.valid? 309 | _(form.errors.details).must_equal( 310 | username: [{error: "not ok"}, {error: "must be yo"}], 311 | email: [{error: :blank}, {error: "fill it out!"}], 312 | policy: [{error: "error_text"}, {error: "another error"}] 313 | ) 314 | _(form.errors.added?(:policy, "error_text")).must_equal true 315 | _(form.errors.added?(:policy, "another error")).must_equal true 316 | _(form.errors.messages).must_equal(email: ["can't be blank", "fill it out!"], username: ["not ok", "must be yo"], policy: ["error_text", "another error"]) 317 | # keep added errors after validate 318 | _(form.validate(username: "username", email: "email@email.com")).must_equal false 319 | if self.class.rails_greater_6_0? 320 | _(form.errors.messages).must_equal(policy: ["error_text", "another error"]) 321 | else 322 | _(form.errors.messages).must_equal(policy: ["error_text", "another error"], username: [], email: []) 323 | end 324 | _(form.errors.added?(:policy, "error_text")).must_equal true 325 | _(form.errors.added?(:policy, "another error")).must_equal true 326 | _(form.errors.details).must_equal( 327 | policy: [{error: "error_text"}, {error: "another error"}] 328 | ) 329 | 330 | form.errors.add(:email, :less_than_or_equal_to, count: 2) 331 | _(form.errors.messages[:email]).must_equal(["must be less than or equal to 2"]) 332 | end 333 | end 334 | 335 | describe "validates: :acceptance" do 336 | class AcceptanceForm < Reform::Form 337 | property :accept, virtual: true, validates: { acceptance: true } 338 | end 339 | 340 | it do 341 | _(AcceptanceForm.new(nil).validate(accept: "0")).must_equal false 342 | end 343 | 344 | it do 345 | _(AcceptanceForm.new(nil).validate(accept: "1")).must_equal true 346 | end 347 | end 348 | 349 | describe "validates_each" do 350 | class ValidateEachForm < Reform::Form 351 | include Reform::Form::ActiveModel::Validations 352 | 353 | property :songs 354 | 355 | validation do 356 | validates_each :songs do |record, attr, value| 357 | record.errors.add attr, "is invalid" unless ['red','green','blue'].include?(value) 358 | end 359 | end 360 | end 361 | 362 | class ValidateEachForm2 < Reform::Form 363 | include Reform::Form::ActiveModel::Validations 364 | 365 | property :songs 366 | 367 | validates_each :songs do |record, attr, value| 368 | record.errors.add attr, "is invalid" unless ['red','green','blue'].include?(value) 369 | end 370 | end 371 | 372 | it { _(ValidateEachForm.new(Album.new).validate(songs: "orange")).must_equal false } 373 | it { _(ValidateEachForm.new(Album.new).validate(songs: "red")).must_equal true } 374 | 375 | it { _(ValidateEachForm2.new(Album.new).validate(songs: "orange")).must_equal false } 376 | it { _(ValidateEachForm2.new(Album.new).validate(songs: "red")).must_equal true } 377 | end 378 | end 379 | 380 | # Regression 381 | # Addresses a bug: https://github.com/trailblazer/reform-rails/issues/103 382 | class ActiveModelValidationWithIfTest < Minitest::Spec 383 | Session = Struct.new(:id) 384 | # Album = Struct.new(:name, :songs, :artist) 385 | # Artist = Struct.new(:name) 386 | 387 | class SessionForm < Reform::Form 388 | include Reform::Form::ActiveModel::Validations 389 | 390 | property :id, virtual: true 391 | 392 | # validates :id, presence: true, if: -> { raise id.inspect } 393 | end 394 | 395 | let (:form) { SessionForm.new(Session.new(2)) } 396 | 397 | # valid. 398 | it do 399 | assert_nil form.id 400 | end 401 | end 402 | --------------------------------------------------------------------------------