├── .github └── workflows │ ├── ci.yml │ ├── ci_jruby.yml │ ├── ci_legacy.yml │ └── ci_truffleruby.yml ├── .gitignore ├── CHANGES.md ├── CONTRIBUTING.md ├── Gemfile ├── ISSUE_TEMPLATE.md ├── LICENSE.txt ├── README.md ├── Rakefile ├── TODO.md ├── lib ├── reform.rb └── reform │ ├── contract.rb │ ├── contract │ ├── custom_error.rb │ └── validate.rb │ ├── errors.rb │ ├── form.rb │ ├── form │ ├── call.rb │ ├── coercion.rb │ ├── composition.rb │ ├── dry.rb │ ├── dry │ │ └── input_hash.rb │ ├── module.rb │ ├── populator.rb │ ├── prepopulate.rb │ └── validate.rb │ ├── result.rb │ ├── validation.rb │ ├── validation │ └── groups.rb │ └── version.rb ├── reform.gemspec └── test ├── benchmarking.rb ├── call_test.rb ├── changed_test.rb ├── coercion_test.rb ├── composition_test.rb ├── contract └── custom_error_test.rb ├── contract_test.rb ├── default_test.rb ├── deserialize_test.rb ├── docs └── validation_test.rb ├── errors_test.rb ├── feature_test.rb ├── fixtures └── dry_error_messages.yml ├── form_option_test.rb ├── form_test.rb ├── from_test.rb ├── inherit_test.rb ├── module_test.rb ├── parse_option_test.rb ├── parse_pipeline_test.rb ├── populate_test.rb ├── populator_skip_test.rb ├── prepopulator_test.rb ├── read_only_test.rb ├── readable_test.rb ├── reform_test.rb ├── save_test.rb ├── setup_test.rb ├── skip_if_test.rb ├── skip_setter_and_getter_test.rb ├── test_helper.rb ├── validate_test.rb ├── validation ├── dry_validation_test.rb └── result_test.rb ├── validation_library_provided_test.rb ├── virtual_test.rb └── writeable_test.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [2.7, '3.0', '3.1', '3.2'] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.github/workflows/ci_jruby.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI JRuby 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [jruby, jruby-head] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.github/workflows/ci_legacy.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI with EOL ruby versions 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [2.5, 2.6] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.github/workflows/ci_truffleruby.yml: -------------------------------------------------------------------------------- 1 | ## This file is managed by Terraform. 2 | ## Do not modify this file directly, as it may be overwritten. 3 | ## Please open an issue instead. 4 | name: CI TruffleRuby 5 | on: [push, pull_request] 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: [truffleruby, truffleruby-head] 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby }} 18 | bundler-cache: true 19 | - run: bundle exec rake 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .rubocop* 19 | .byebug_history 20 | .idea 21 | *.iml 22 | gemfiles/*.gemfile.lock 23 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 2.6.2 2 | 3 | * Loosen `representable` dependency to `< 4`. 4 | 5 | ## 2.6.1 6 | 7 | * Loosen `disposable` dependency to `>= 0.5.0`. 8 | 9 | ## 2.6.0 10 | 11 | * Support ruby-3 by using `Representable::Option` to handle `keyword_arguments` forwarding :tada: 12 | * Upgrade `representable` and `disposable` dependencies which uses `trailblazer-option` over `declarative-option`. 13 | * Deprecate populator's callable signature which accepts `form` as a separate positional argument. Make all callable (proc, method, `Uber::Callable`) signatures identical. 14 | 15 | ## 2.5.0 16 | * fix memory leak with Dry validation (#525) 17 | 18 | ## 2.4.0 19 | 20 | * [BREAKING] Dropping compatibility of dry-validation < 1.x 21 | [* Removed `Reform::Contract` ?] 22 | [* Move Form#deserializer to Form::deserializer] 23 | 24 | ## 2.3.3 25 | 26 | * Rename validation option for dry-v 1+ to `contract` instead of `schema` 27 | 28 | ## 2.3.2 29 | 30 | * Fix Validation block option :form incorrectly memoized between tests 31 | 32 | ## 2.3.1 33 | * With dry-validation 1.5 the form is always injected. Just add option :form to access it in the schema. 34 | * Removed global monkey patching of Dry::Schema::DSL 35 | * Tests in ruby 2.7 36 | 37 | ## 2.3.0 38 | 39 | You can upgrade from 2.2.0 without worries. 40 | 41 | * Require Representable 3.0.0 and **removed Representable 2.4 deprecation code**. 42 | * Require Disposable 0.4.0 which fixes issues with `nil` field values, `sync {}` and dry-validation. 43 | * Fix boolean coercion. 44 | * Allow using `:populator` classes marked with `Uber::Callable`. 45 | * Introduce `parse: false` as a shortcut for `deserialzer: { writeable: false}`. Thanks to @pabloh for insisting on this handy change. 46 | * Memoize the deserializer instance on the class level via `::deserializer`. This saves the inferal of a deserializing representer and speeds up following calls by 130%. 47 | * Deprecated positional arguments for `validation :default, options: {}`. New API: `validation name: :default, **`. 48 | * Reform now maintains a generic `Dry::Schema` class for global schema configuration. Can be overridden via `::validation`. 49 | * When validating with dry-validation, we now pass a symbolized hash. We also replaced `Dry::Validation::Form` with `Schema` which won't coerce values where it shouldn't. 50 | * [private] `Group#call` API now is: `call(form, errors)`. 51 | * Modify `Form#valid?` - simply calls `validate({})`. 52 | * In `:if` for validation groups, you now get a hash of result objects, not just true/false. 53 | * Allow adding a custom error AFTER validate has been already called 54 | 55 | Compatibility with `dry-validation` with 1.x: 56 | * [CHANGE] seems like "custom" predicate are not supported by `dry-schema` anymore or better the same result is reached using the `rule` method: 57 | Something like this: 58 | ```ruby 59 | validation do 60 | def a_song?(value) 61 | value == :really_cool_song 62 | end 63 | 64 | required(:songs).filled(:a_song?) 65 | end 66 | ``` 67 | will be something like: 68 | ```ruby 69 | validation do 70 | required(:songs).filled 71 | 72 | rule(:songs) do 73 | key.failure(:a_song?) unless value == :really_cool_song 74 | end 75 | end 76 | ``` 77 | * [BREAKING] inheriting/merging/overriding schema/rules is not supported by `dry-v` so the `inherit:` option is **NOT SUPPORTED** for now. Also extend a `schema:` option using a block is **NOT SUPPORTED** for now. Possible workaround is to use reform module to compose different validations but this won't override existing validations but just merge them 78 | 79 | ## 2.2.4 80 | 81 | * You can now use any object with `call` as a populator, no need to `include Uber::Callable` anymore. This is because we have only three types and don't need a `is_a?` or `respond_to?` check. 82 | * Use `declarative-option` and loosen `uber` dependency. 83 | 84 | ## 2.2.3 85 | 86 | * Add `Form#call` as an alias for `validate` and the `Result` object. 87 | 88 | ## 2.2.2 89 | 90 | * Loosen `uber` dependency. 91 | 92 | ## 2.2.1 93 | 94 | * Fix `Contract::Properties`. Thanks @simonc. <3 95 | 96 | ## 2.2.0 97 | 98 | * Remove `reform/rails`. This is now handled via the `reform-rails` gem which you have to bundle. 99 | * For coercion, we now use [dry-types](https://github.com/dry-rb/dry-types) as a replacement for the deprecated virtus. You have to change to dry-types' constants, e.g. `type: Types::Form::Bool`. 100 | * Use disposable 0.3.0. This gives us the long-awaited `nilify: true` option. 101 | 102 | ####### TODO: fix Module and coercion Types::* 103 | 104 | ## 2.1.0 105 | 106 | You should be able to upgrade from 2.0 without any code changes. 107 | 108 | ### Awesomeness 109 | 110 | * You can now have `:populator` for scalar properties, too. This allows "parsing code" per property which is super helpful to structure your deserialization. 111 | * `:populator` can be a method name, as in `populator: :populate_authors!`. 112 | * Populators can now skip deserialization of a nested fragment using `skip!`. [Learn more here](http://trailblazer.to/gems/reform/populator.html#skip). 113 | * Added support for dry-validation as a future replacement for ActiveModel::Validation. Note that this is still experimental, but works great. 114 | * Added validation groups. 115 | 116 | ### Changes 117 | 118 | * All lambda APIs change (with deprecation): `populator: ->(options)` or `->(fragment:, model:, **o)` where we only receive one hash instead of a varying number or arguments. This is pretty cool and should be listed under _Awesomeness_. 119 | * `ActiveModel::Validator` prevents Rails from adding methods to it. This makes `acceptance` and `confirmation` validations work properly. 120 | 121 | ### Notes 122 | 123 | * Please be warned that we will drop support for `ActiveModel::Validations` from 2.2 onwards. Don't worry, it will still work, but we don't want to work with it anymore. 124 | 125 | ## 2.0.5 126 | 127 | * `ActiveModel::Validator` now delegates all methods properly to the form. It used to crashed with properties called `format` or other private `Object` methods. 128 | * Simpleform will now properly display fields as required, or not (by introducion `ModelReflections::validators_on`). 129 | * The `:default` option is not copied into the deserializer anymore from the schema. This requires disposable 0.1.11. 130 | 131 | ## 2.0.4 132 | 133 | * `#sync` and `#save` with block now provide `HashWithIndifferentAccess` in Rails. 134 | 135 | ## 2.0.3 136 | 137 | * `Form#valid?` is private now. Sorry for the inconvenience, but this has never been documented as public. Reason is that the only entry point for validation is `#validate` to give the form as less public API as possible and minimize misunderstandings. 138 | 139 | The idea is that you set up the object graph before/while `#validate` and then invoke the validators once. 140 | * Fixed AM to find proper i18n for error messages. This happens by injecting the form's `model_name` into the `Validator` object in ActiveModel. 141 | 142 | ## 2.0.2 143 | 144 | * Fix `unique: true` validation in combination with `Composition`. 145 | * Use newest Disposable 0.1.9 which does not set `:pass_options` anymore. 146 | 147 | ## 2.0.1 148 | 149 | * Fix `ActiveModel::Validations` where translations in custom validations would error. This is now handled by delegating back to the `Validator` object in Reform. 150 | 151 | ## 2.0.0 152 | 153 | * The `::reform_2_0!` is no longer there. Guess why. 154 | * Again: `:empty` doesn't exist anymore. You can choose from `:readable`, `:writeable` and `:virtual`. 155 | * When using `:populator` the API to work against the form has changed. 156 | ```ruby 157 | populator: lambda { |fragment, index, args| 158 | songs[index] or songs[index] = args.binding[:form].new(Song.new) 159 | } 160 | ``` 161 | 162 | is now 163 | 164 | ```ruby 165 | populator: lambda { |fragment, index, args| 166 | songs[index] or songs.insert(index) = Song.new 167 | } 168 | ``` 169 | You don't need to know about forms anymore, the twin handles that using the [Twin](https://github.com/apotonick/disposable) API.. 170 | 171 | * `:as` option removed. Use `:from`. 172 | * With `Composition` included, `Form#model` would give you a composition object. You can grab that using `Form#mapper` now. 173 | * `Form#update!` is deprecated. It still works but will remind you to override `#present!` or use pre-populators as [described here](http://trailblazerb.org/gems/reform/prepopulator.html) and in the Trailblazer book, chapter "Nested Forms". 174 | * Forms do not `include ActiveModel::Validations` anymore. This has polluted the entire gem and is not encapsulated in `Validator`. Consider using Lotus Validations instead. 175 | * Validation inheritance with `ActiveModel::Validations` is broken with Rails 3.2 and 4.0. Update Rails or use the `Lotus` validations. 176 | 177 | ## 2.0.0.rc3 178 | 179 | * Fix an annoying bug coming from Rails autoloader with validations and `model_name`. 180 | 181 | ## 1.2.6 182 | 183 | * Added `:prepopulate` to fill out form properties for presentation. Note that you need to call `Form#prepopulate!` to trigger the prepopulation. 184 | * Added support for DateTime properties in forms. Until now, we were ignoring the time part. Thanks to @gdott9 for fixing this. 185 | 186 | ## 1.2.5 187 | 188 | * Added `Form#options_for` to have access to all property options. 189 | 190 | ## 1.2.4 191 | 192 | * Added `Form#readonly?` to find out whether a field is set to writeable. This is helpful for simple_form to display a disabled input field. 193 | 194 | ```ruby 195 | property :title, writeable: false 196 | ``` 197 | 198 | In the view, you can then use something along the following code. 199 | 200 | ```ruby 201 | f.input :title, readonly: @form.readonly?(:title) 202 | ``` 203 | 204 | ## 1.2.3 205 | 206 | * Make `ModelReflections` work with simple_form 3.1.0. (#176). It also provides `defined_enums` and `::reflect_on_association` now. 207 | * `nil` values passed into `#validate` will now be written to the model in `#sync` (#175). Formerly, only blank strings and values evaluating to true were considered when syncing. This allows blanking fields of the model as follows. 208 | 209 | ```ruby 210 | form.validate(title: nil) 211 | ``` 212 | * Calling `Form::reform_2_0!` will now properly inherit to nested forms. 213 | 214 | ## 1.2.2 215 | 216 | * Use new `uber` to allow subclassing `reform_2_0!` forms. 217 | * Raise a better understandable deserialization error when the form is not populated properly. This error is so common that I overcame myself to add a dreaded `rescue` block in `Form#validate`. 218 | 219 | ## 1.2.1 220 | 221 | * Fixed a nasty bug where `ActiveModel` forms with form builder support wouldn't deserialize properly. A million Thanks to @karolsarnacki for finding this and providing an exemplary failing test. <3 222 | 223 | ## 1.2.0 224 | 225 | ### Breakage 226 | 227 | * Due to countless bugs we no longer include support for simple_form's type interrogation automatically. This allows using forms with non-AM objects. If you want full support for simple_form do as follows. 228 | 229 | ```ruby 230 | class SongForm < Reform::Form 231 | include ModelReflections 232 | ``` 233 | 234 | Including this module will add `#column_for_attribute` and other methods need by form builders to automatically guess the type of a property. 235 | 236 | * `Form#save` no longer passed `self` to the block. You've been warned long enough. ;) 237 | 238 | ### Changes 239 | 240 | * Renamed `:as` to `:from` to be in line with Representable/Roar, Disposable and Cells. Thanks to @bethesque for pushing this. 241 | * `:empty` is now `:virtual` and `:virtual` is `writeable: false`. It was too confusing and sucked. Thanks to @bethesque, again, for her moral assistance. 242 | * `Form#save` with `Composition` now returns true only if all composite models saved. 243 | * `Form::copy_validations_from` allows copying custom validators now. 244 | * New call style for `::properties`. Instead of an array, it's now `properties :title, :genre`. 245 | * All options are evaluated with `pass_options: true`. 246 | * All transforming representers are now created and stored on class level, resulting in simpler code and a 85% speed-up. 247 | 248 | ### New Stuff!!! 249 | 250 | * In `#validate`, you can ignore properties now using `:skip_if`. 251 | 252 | ```ruby 253 | property :hit, skip_if: lambda { |fragment, *| fragment["title"].blank? } 254 | ``` 255 | 256 | This works for both properties and nested forms. The property will simply be ignored when deserializing, as if it had never been in the incoming hash/document. 257 | 258 | For nested properties you can use `:skip_if: :all_blank` as a macro to ignore a nested form if all values are blank. 259 | * You can now specify validations right in the `::property` call. 260 | 261 | ```ruby 262 | property :title, validates: {presence: true} 263 | ``` 264 | 265 | Thanks to @zubin for this brillant idea! 266 | 267 | * Reform now tracks which attributes have changed after `#validate`. You can check that using `form.changed?(:title)`. 268 | * When including `Sync::SkipUnchanged`, the form won't try to assign unchanged values anymore in `#sync`. This is extremely helpful when handling file uploads and the like. 269 | * Both `#sync` and `#save` can be configured dynamically now. 270 | 271 | When syncing, you can run a lambda per property. 272 | 273 | ```ruby 274 | property :title, sync: lambda { |value, options| model.set_title(value) } 275 | ``` 276 | 277 | This won't run Reform's built-in sync for this property. 278 | 279 | You can also provide the sync lambda at run-time. 280 | 281 | ```ruby 282 | form.sync(title: lambda { |value, options| form.model.title = "HOT: #{value}" }) 283 | ``` 284 | 285 | This block is run in the caller's context allowing you to access environment variables. 286 | 287 | Note that the dynamic sync happens _before_ save, so the model id may unavailable. 288 | 289 | You can do the same for saving. 290 | 291 | ```ruby 292 | form.save(title: lambda { |value, options| form.model.title = "#{form.model.id} --> #{value}" }) 293 | ``` 294 | Again, this block is run in the caller's context. 295 | 296 | The two features are an excellent way to handle file uploads without ActiveRecord's horrible callbacks. 297 | 298 | * Adding generic `:base` errors now works. Thanks to @bethesque. 299 | 300 | ```ruby 301 | errors.add(:base, "You are too awesome!") 302 | ``` 303 | 304 | This will prefix the error with `:base`. 305 | * Need your form to parse JSON? Include `Reform::Form::JSON`, the `#validate` method now expects a JSON string and will deserialize and populate the form from the JSON document. 306 | * Added `Form::schema` to generate a pure representer from the form's representer. 307 | * Added `:readable` and `:writeable` option which allow to skip reading or writing to the model when `false`. 308 | 309 | ## 1.1.1 310 | 311 | * Fix a bug where including a form module would mess up the options has of the validations (under older Rails). 312 | * Fix `::properties` which modified the options hash while iterating properties. 313 | * `Form#save` now returns the result of the `model.save` invocation. 314 | * Fix: When overriding a reader method for a nested form for presentation (e.g. to provide an initial new record), this reader was used in `#update!`. The deserialize/update run now grabs the actual nested form instances directly from `fields`. 315 | * `Errors#to_s` is now delegated to `messages.to_s`. This is used in `Trailblazer::Operation`. 316 | 317 | ## 1.1.0 318 | 319 | * Deprecate first block argument in save. It's new signature is `save { |hash| }`. You already got the form instance when calling `form.save` so there's no need to pass it into the block. 320 | * `#validate` does **not** touch any model anymore. Both single values and collections are written to the model after `#sync` or `#save`. 321 | * Coercion now happens in `#validate`, only. 322 | * You can now define forms in modules including `Reform::Form::Module` to improve reusability. 323 | * Inheriting from forms and then overriding/extending properties with `:inherit` now works properly. 324 | * You can now define methods in inline forms. 325 | * Added `Form::ActiveModel::ModelValidations` to copy validations from model classes. Thanks to @cameron-martin for this fine addition. 326 | * Forms can now also deserialize other formats, e.g. JSON. This allows them to be used as a contract for API endpoints and in Operations in Trailblazer. 327 | * Composition forms no longer expose readers to the composition members. The composition is available via `Form#model`, members via `Form#model[:member_name]`. 328 | * ActiveRecord support is now included correctly and passed on to nested forms. 329 | * Undocumented/Experimental: Scalar forms. This is still WIP. 330 | 331 | ## 1.0.4 332 | 333 | Reverting what I did in 1.0.3. Leave your code as it is. You may override a writers like `#title=` to sanitize or filter incoming data, as in 334 | 335 | ```ruby 336 | def title=(v) 337 | super(v.strip) 338 | end 339 | ``` 340 | 341 | This setter will only be called in `#validate`. 342 | 343 | Readers still work the same, meaning that 344 | 345 | ```ruby 346 | def title 347 | super.downcase 348 | end 349 | ``` 350 | 351 | will result in lowercased title when rendering the form (and only then). 352 | 353 | The reason for this confusion is that you don't blog enough about Reform. Only after introducing all those deprecation warnings, people started to contact me to ask what's going on. This gave me the feedback I needed to decide what's the best way for filtering incoming data. 354 | 355 | ## 1.0.3 356 | 357 | * Systematically use `fields` when saving the form. This avoids calling presentational readers that might have been defined by the user. 358 | 359 | ## 1.0.2 360 | 361 | * The following property names are reserved and will raise an exception: `[:model, :aliased_model, :fields, :mapper]` 362 | * You get warned now when overriding accessors for your properties: 363 | 364 | ```ruby 365 | property :title 366 | 367 | def title 368 | super.upcase 369 | end 370 | ``` 371 | 372 | This is because in Reform 1.1, those accessors will only be used when rendering the form, e.g. when doing `= @form.title`. If you override the accessors for presentation, only, you're fine. Add `presentation_accessors: true` to any property, the warnings will be suppressed and everything's gonna work. You may remove `presentation_accessors: true` in 1.1, but it won't affect the form. 373 | 374 | However, if you used to override `#title` or `#title=` to manipulate incoming data, this is no longer working in 1.1. The reason for this is to make Reform cleaner. You will get two options `:validate_processor` and `:sync_processor` in order to filter data when calling `#validate` and when syncing data back to the model with `#sync` or `#save`. 375 | 376 | ## 1.0.1 377 | 378 | * Deprecated model readers for `Composition` and `ActiveModel`. Consider the following setup. 379 | ```ruby 380 | class RecordingForm < Reform::Form 381 | include Composition 382 | 383 | property :title, on: :song 384 | end 385 | ``` 386 | 387 | Before, Reform would allow you to do `form.song` which returned the song model. You can still do this (but you shouldn't) with `form.model[:song]`. 388 | 389 | This allows having composed models and properties with the same name. Until 1.1, you have to use `skip_accessors: true` to advise Reform _not_ to create the deprecated accessor. 390 | 391 | Also deprecated is the alias accessor as found with `ActiveModel`. 392 | ```ruby 393 | class RecordingForm < Reform::Form 394 | include Composition 395 | include ActiveModel 396 | 397 | model :hit, on: :song 398 | end 399 | ``` 400 | Here, an automatic reader `Form#hit` was created. This is deprecated as 401 | 402 | This is gonna be **removed in 1.1**. 403 | 404 | 405 | ## 1.0.0 406 | 407 | * Removed `Form::DSL` in favour of `Form::Composition`. 408 | * Simplified nested forms. You can now do 409 | ```ruby 410 | validates :songs, :length => {:minimum => 1} 411 | validates :hit, :presence => true 412 | ``` 413 | * Allow passing symbol hash keys into `#validate`. 414 | * Unlimited nesting of forms, if you really want that. 415 | * `save` gets called on all nested forms automatically, disable with `save: false`. 416 | * Renaming with `as:` now works everywhere. 417 | * Fixes to make `Composition` work everywhere. 418 | * Extract setup and validate into `Contract`. 419 | * Automatic population with `:populate_if_empty` in `#validate`. 420 | * Remove `#from_hash` and `#to_hash`. 421 | * Introduce `#sync` and make `#save` less smart. 422 | 423 | ## 0.2.7 424 | 425 | * Last release supporting Representable 1.7. 426 | * In ActiveModel/ActiveRecord: The model name is now correctly infered even if the name is something like `Song::Form`. 427 | 428 | ## 0.2.6 429 | 430 | * Maintenance release cause I'm stupid. 431 | 432 | ## 0.2.5 433 | 434 | * Allow proper form inheritance. When having `HitForm < SongForm < Reform::Form` the `HitForm` class will contain `SongForm`'s properties in addition to its own fields. 435 | * `::model` is now inherited properly. 436 | * Allow instantiation of nested form with emtpy nested properties. 437 | 438 | ## 0.2.4 439 | 440 | * Accessors for properties (e.g. `title` and `title=`) can now be overridden in the form *and* call `super`. This is extremely helpful if you wanna do "manual coercion" since the accessors are invoked in `#validate`. Thanks to @cj for requesting this. 441 | * Inline forms now know their class name from the property that defines them. This is needed for I18N where `ActiveModel` queries the class name to compute translation keys. If you're not happy with it, use `::model`. 442 | 443 | ## 0.2.3 444 | 445 | * `#form_for` now properly recognizes a nested form when declared using `:form` (instead of an inline form). 446 | * Multiparameter dates as they're constructed from the Rails date helper are now processed automatically. As soon as an incoming attribute name is `property_name(1i)` or the like, it's compiled into a Date. That happens in `MultiParameterAttributes`. If a component (year/month/day) is missing, the date is considered `nil`. 447 | 448 | ## 0.2.2 449 | 450 | * Fix a bug where `form.save do .. end` would call `model.save` even though a block was given. This no longer happens, if there's a block to `#save`, you have to manually save data (ActiveRecord environment, only). 451 | * `#validate` doesn't blow up anymore when input data is missing for a nested property or collection. 452 | * Allow `form: SongForm` to specify an explicit form class instead of using an inline form for nested properties. 453 | 454 | ## 0.2.1 455 | 456 | * `ActiveRecord::i18n_scope` now returns `activerecord`. 457 | * `Form#save` now calls save on the model in `ActiveRecord` context. 458 | * `Form#model` is public now. 459 | * Introduce `:empty` to have empty fields that are accessible for validation and processing, only. 460 | * Introduce `:virtual` for read-only fields the are like `:empty` but initially read from the decorated model. 461 | * Fix uniqueness validation with `Composition` form. 462 | * Move `setup` and `save` logic into respective representer classes. This might break your code in case you overwrite private reform classes. 463 | 464 | 465 | ## 0.2.0 466 | 467 | * Added nested property and collection for `has_one` and `has_many` relationships. . Note that this currently works only 1-level deep. 468 | * Renamed `Reform::Form::DSL` to `Reform::Form::Composition` and deprecated `DSL`. 469 | * `require 'reform'` now automatically requires Rails stuff in a Rails environment. Mainly, this is the FormBuilder compatibility layer that is injected into `Form`. If you don't want that, only require 'reform/form'. 470 | * Composition now totally optional 471 | * `Form.new` now accepts one argument, only: the model/composition. If you want to create your own representer, inject it by overriding `Form#mapper`. Note that this won't create property accessors for you. 472 | * `Form::ActiveModel` no longer creates accessors to your represented models, e.g. having `property :title, on: :song` doesn't allow `form.song` anymore. This is because the actual model and the form's state might differ, so please use `form.title` directly. 473 | 474 | ## 0.1.3 475 | 476 | * Altered `reform/rails` to conditionally load `ActiveRecord` code and created `reform/active_record`. 477 | 478 | ## 0.1.2 479 | 480 | * `Form#to_model` is now delegated to model. 481 | * Coercion with virtus works. 482 | 483 | ## 0.1.1 484 | 485 | * Added `reform/rails` that requires everything you need (even in other frameworks :). 486 | * Added `Form::ActiveRecord` that gives you `validates_uniqueness_with`. Note that this is strongly coupled to your database, thou. 487 | * Merged a lot of cleanups from sweet @parndt <3. 488 | 489 | ## 0.1.0 490 | 491 | * Oh yeah. 492 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to Reform 2 | 3 | #### **Did you find a bug?** 4 | 5 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/trailblazer/reform/issues). 6 | 7 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/trailblazer/reform/issues/new). Be sure to follow the issue template. 8 | 9 | #### **Did you write a patch that fixes a bug?** 10 | 11 | * Open a new GitHub pull request with the patch. 12 | 13 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 14 | 15 | * All code in pull requests is assumed to be MIT licensed. Do not submit a pull request if that isn't the case. 16 | 17 | #### **Do you intend to add a new feature or change an existing one?** 18 | 19 | * Suggest your change in the [Trailblazer Gitter Room](https://gitter.im/trailblazer/chat) and start writing code. 20 | 21 | * Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. 22 | 23 | #### **Do you have questions using Reform?** 24 | 25 | * Ask any questions about how to use Reform in the [Trailblazer Gitter Room](https://gitter.im/trailblazer/chat). Github issues are restricted to bug reports and fixes. 26 | 27 | * GitHub Issues should not be used as a help forum and any such issues will be closed. 28 | 29 | #### **Do you want to contribute to the Reform documentation?** 30 | 31 | * Reform documentation is provided via the [Trailblazer site](http://trailblazer.to/gems/reform/) and not the repository readme. Please add your contributions to the [Trailblazer site repository](https://github.com/trailblazer/trailblazer.github.io) 32 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'dry-validation', '~> 1.5.0' 6 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Note: If you have a question about Reform, would like help using 2 | Reform, want to request a feature, or do anything else other than 3 | submit a bug report, please use the [Trailblazer gitter channel](https://gitter.im/trailblazer/chat). 4 | 5 | Note: Rails/ ActiveRecord/ ActiveModel support. 6 | As of Reform 2.2.0 all Rails/ Active-* code was moved to the [reform-rails](https://github.com/trailblazer/reform-rails) gem. 7 | Make sure you are contributing to the correct gem! 8 | 9 | ### Complete Description of Issue 10 | 11 | 12 | ### Steps to reproduce 13 | 14 | 15 | ### Expected behavior 16 | Tell us what should happen 17 | 18 | ### Actual behavior 19 | Tell us what happens instead 20 | 21 | ### System configuration 22 | **Reform version**: 23 | 24 | ### Full Backtrace of Exception (if any) 25 | 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 - 2021 Nick Sutterer 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reform 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.svg)](https://travis-ci.org/trailblazer/reform) 7 | [![Gem Version](https://badge.fury.io/rb/reform.svg)](http://badge.fury.io/rb/reform) 8 | 9 | _Form objects decoupled from your models._ 10 | 11 | Reform gives you a form object with validations and nested setup of models. It is completely framework-agnostic and doesn't care about your database. 12 | 13 | Although reform can be used in any Ruby framework, it comes with [Rails support](#rails-integration), works with simple_form and other form gems, allows nesting forms to implement has_one and has_many relationships, can [compose a form](#compositions) from multiple objects and gives you coercion. 14 | 15 | ## Full Documentation 16 | 17 | Reform is part of the [Trailblazer](http://trailblazer.to) framework. [Full documentation](http://trailblazer.to/2.1/docs/reform.html) is available on the project site. 18 | 19 | ## Reform 2.2 20 | 21 | Temporary note: Reform 2.2 does **not automatically load Rails files** anymore (e.g. `ActiveModel::Validations`). You need the `reform-rails` gem, see [Installation](#installation). 22 | 23 | ## Defining Forms 24 | 25 | Forms are defined in separate classes. Often, these classes partially map to a model. 26 | 27 | ```ruby 28 | class AlbumForm < Reform::Form 29 | property :title 30 | validates :title, presence: true 31 | end 32 | ``` 33 | 34 | Fields are declared using `::property`. Validations work exactly as you know it from Rails or other frameworks. Note that validations no longer go into the model. 35 | 36 | 37 | ## The API 38 | 39 | Forms have a ridiculously simple API with only a handful of public methods. 40 | 41 | 1. `#initialize` always requires a model that the form represents. 42 | 2. `#validate(params)` updates the form's fields with the input data (only the form, _not_ the model) and then runs all validations. The return value is the boolean result of the validations. 43 | 3. `#errors` returns validation messages in a classic ActiveModel style. 44 | 4. `#sync` writes form data back to the model. This will only use setter methods on the model(s). 45 | 5. `#save` (optional) will call `#save` on the model and nested models. Note that this implies a `#sync` call. 46 | 6. `#prepopulate!` (optional) will run pre-population hooks to "fill out" your form before rendering. 47 | 48 | In addition to the main API, forms expose accessors to the defined properties. This is used for rendering or manual operations. 49 | 50 | 51 | ## Setup 52 | 53 | In your controller or operation you create a form instance and pass in the models you want to work on. 54 | 55 | ```ruby 56 | class AlbumsController 57 | def new 58 | @form = AlbumForm.new(Album.new) 59 | end 60 | ``` 61 | 62 | This will also work as an editing form with an existing album. 63 | 64 | ```ruby 65 | def edit 66 | @form = AlbumForm.new(Album.find(1)) 67 | end 68 | ``` 69 | 70 | Reform will read property values from the model in setup. In our example, the `AlbumForm` will call `album.title` to populate the `title` field. 71 | 72 | ## Rendering Forms 73 | 74 | Your `@form` is now ready to be rendered, either do it yourself or use something like Rails' `#form_for`, `simple_form` or `formtastic`. 75 | 76 | ```haml 77 | = form_for @form do |f| 78 | = f.input :title 79 | ``` 80 | 81 | Nested forms and collections can be easily rendered with `fields_for`, etc. Note that you no longer pass the model to the form builder, but the Reform instance. 82 | 83 | Optionally, you might want to use the `#prepopulate!` method to pre-populate fields and prepare the form for rendering. 84 | 85 | 86 | ## Validation 87 | 88 | After form submission, you need to validate the input. 89 | 90 | ```ruby 91 | class SongsController 92 | def create 93 | @form = SongForm.new(Song.new) 94 | 95 | #=> params: {song: {title: "Rio", length: "366"}} 96 | 97 | if @form.validate(params[:song]) 98 | ``` 99 | 100 | The `#validate` method first updates the values of the form - the underlying model is still treated as immutuable and *remains unchanged*. It then runs all validations you provided in the form. 101 | 102 | It's the only entry point for updating the form. This is per design, as separating writing and validation doesn't make sense for a form. 103 | 104 | This allows rendering the form after `validate` with the data that has been submitted. However, don't get confused, the model's values are still the old, original values and are only changed after a `#save` or `#sync` operation. 105 | 106 | 107 | ## Syncing Back 108 | 109 | After validation, you have two choices: either call `#save` and let Reform sort out the rest. Or call `#sync`, which will write all the properties back to the model. In a nested form, this works recursively, of course. 110 | 111 | It's then up to you what to do with the updated models - they're still unsaved. 112 | 113 | 114 | ## Saving Forms 115 | 116 | The easiest way to save the data is to call `#save` on the form. 117 | 118 | ```ruby 119 | if @form.validate(params[:song]) 120 | @form.save #=> populates album with incoming data 121 | # by calling @form.album.title=. 122 | else 123 | # handle validation errors. 124 | end 125 | ``` 126 | 127 | This will sync the data to the model and then call `album.save`. 128 | 129 | Sometimes, you need to do saving manually. 130 | 131 | ## Default values 132 | 133 | Reform allows default values to be provided for properties. 134 | 135 | ```ruby 136 | class AlbumForm < Reform::Form 137 | property :price_in_cents, default: 9_95 138 | end 139 | ``` 140 | 141 | ## Saving Forms Manually 142 | 143 | Calling `#save` with a block will provide a nested hash of the form's properties and values. This does **not call `#save` on the models** and allows you to implement the saving yourself. 144 | 145 | The block parameter is a nested hash of the form input. 146 | 147 | ```ruby 148 | @form.save do |hash| 149 | hash #=> {title: "Greatest Hits"} 150 | Album.create(hash) 151 | end 152 | ``` 153 | 154 | You can always access the form's model. This is helpful when you were using populators to set up objects when validating. 155 | 156 | ```ruby 157 | @form.save do |hash| 158 | album = @form.model 159 | 160 | album.update_attributes(hash[:album]) 161 | end 162 | ``` 163 | 164 | 165 | ## Nesting 166 | 167 | Reform provides support for nested objects. Let's say the `Album` model keeps some associations. 168 | 169 | ```ruby 170 | class Album < ActiveRecord::Base 171 | has_one :artist 172 | has_many :songs 173 | end 174 | ``` 175 | 176 | The implementation details do not really matter here, as long as your album exposes readers and writes like `Album#artist` and `Album#songs`, this allows you to define nested forms. 177 | 178 | 179 | ```ruby 180 | class AlbumForm < Reform::Form 181 | property :title 182 | validates :title, presence: true 183 | 184 | property :artist do 185 | property :full_name 186 | validates :full_name, presence: true 187 | end 188 | 189 | collection :songs do 190 | property :name 191 | end 192 | end 193 | ``` 194 | 195 | You can also reuse an existing form from elsewhere using `:form`. 196 | 197 | ```ruby 198 | property :artist, form: ArtistForm 199 | ``` 200 | 201 | ## Nested Setup 202 | 203 | Reform will wrap defined nested objects in their own forms. This happens automatically when instantiating the form. 204 | 205 | ```ruby 206 | album.songs #=> [] 207 | 208 | form = AlbumForm.new(album) 209 | form.songs[0] #=> > 210 | form.songs[0].name #=> "Run To The Hills" 211 | ``` 212 | 213 | ### Nested Rendering 214 | 215 | When rendering a nested form you can use the form's readers to access the nested forms. 216 | 217 | ```haml 218 | = text_field :title, @form.title 219 | = text_field "artist[name]", @form.artist.name 220 | ``` 221 | 222 | Or use something like `#fields_for` in a Rails environment. 223 | 224 | ```haml 225 | = form_for @form do |f| 226 | = f.text_field :title 227 | 228 | = f.fields_for :artist do |a| 229 | = a.text_field :name 230 | ``` 231 | 232 | ## Nested Processing 233 | 234 | `validate` will assign values to the nested forms. `sync` and `save` work analogue to the non-nested form, just in a recursive way. 235 | 236 | The block form of `#save` would give you the following data. 237 | 238 | ```ruby 239 | @form.save do |nested| 240 | nested #=> {title: "Greatest Hits", 241 | # artist: {name: "Duran Duran"}, 242 | # songs: [{title: "Hungry Like The Wolf"}, 243 | # {title: "Last Chance On The Stairways"}] 244 | # } 245 | end 246 | ``` 247 | 248 | The manual saving with block is not encouraged. You should rather check the Disposable docs to find out how to implement your manual tweak with the official API. 249 | 250 | 251 | ## Populating Forms 252 | 253 | Very often, you need to give Reform some information how to create or find nested objects when `validate`ing. This directive is called _populator_ and [documented here](http://trailblazer.to/2.1/docs/reform.html#reform-populators). 254 | 255 | ## Installation 256 | 257 | Add this line to your Gemfile: 258 | 259 | ```ruby 260 | gem "reform" 261 | ``` 262 | 263 | Reform works fine with Rails 3.1-5.0. However, inheritance of validations with `ActiveModel::Validations` is broken in Rails 3.2 and 4.0. 264 | 265 | Since Reform 2.2, you have to add the `reform-rails` gem to your `Gemfile` to automatically load ActiveModel/Rails files. 266 | 267 | ```ruby 268 | gem "reform-rails" 269 | ``` 270 | 271 | Since Reform 2.0 you need to specify which **validation backend** you want to use (unless you're in a Rails environment where ActiveModel will be used). 272 | 273 | To use ActiveModel (not recommended because very out-dated). 274 | 275 | ```ruby 276 | require "reform/form/active_model/validations" 277 | Reform::Form.class_eval do 278 | include Reform::Form::ActiveModel::Validations 279 | end 280 | ``` 281 | 282 | To use dry-validation (recommended). 283 | 284 | ```ruby 285 | require "reform/form/dry" 286 | Reform::Form.class_eval do 287 | feature Reform::Form::Dry 288 | end 289 | ``` 290 | 291 | Put this in an initializer or on top of your script. 292 | 293 | 294 | ## Compositions 295 | 296 | Reform allows to map multiple models to one form. The [complete documentation](https://github.com/apotonick/disposable#composition) is here, however, this is how it works. 297 | 298 | ```ruby 299 | class AlbumForm < Reform::Form 300 | include Composition 301 | 302 | property :id, on: :album 303 | property :title, on: :album 304 | property :songs, on: :cd 305 | property :cd_id, on: :cd, from: :id 306 | end 307 | ``` 308 | When initializing a composition, you have to pass a hash that contains the composees. 309 | 310 | ```ruby 311 | AlbumForm.new(album: album, cd: CD.find(1)) 312 | ``` 313 | 314 | ## More 315 | 316 | Reform comes many more optional features, like hash fields, coercion, virtual fields, and so on. Check the [full documentation here](http://trailblazer.to/2.1/docs/reform.html). 317 | 318 | [![](http://trailblazer.to/images/3dbuch-freigestellt.png)](https://leanpub.com/trailblazer) 319 | 320 | Reform is part of the [Trailblazer project](http://trailblazer.to). Please [buy my book](https://leanpub.com/trailblazer) to support the development and learn everything about Reform - there's two chapters dedicated to Reform! 321 | 322 | 323 | ## Security And Strong_parameters 324 | 325 | By explicitly defining the form layout using `::property` there is no more need for protecting from unwanted input. `strong_parameter` or `attr_accessible` become obsolete. Reform will simply ignore undefined incoming parameters. 326 | 327 | ## This is not Reform 1.x! 328 | 329 | Temporary note: This is the README and API for Reform 2. On the public API, only a few tiny things have changed. Here are the [Reform 1.2 docs](https://github.com/trailblazer/reform/blob/v1.2.6/README.md). 330 | 331 | Anyway, please upgrade and _report problems_ and do not simply assume that we will magically find out what needs to get fixed. When in trouble, join us on [Gitter](https://gitter.im/trailblazer/chat). 332 | 333 | [Full documentation for Reform](http://trailblazer.to/2.1/docs/reform.html) is available online, or support us and grab the [Trailblazer book](https://leanpub.com/trailblazer). There is an [Upgrading Guide](http://trailblazer.to/2.1/docs/reform.html#reform-upgrading-guide) to help you migrate through versions. 334 | 335 | ### Attributions!!! 336 | 337 | Great thanks to [Blake Education](https://github.com/blake-education) for giving us the freedom and time to develop this project in 2013 while working on their project. 338 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "dry/types/version" 4 | 5 | task default: %i[test] 6 | 7 | Rake::TestTask.new(:test) do |test| 8 | test.libs << "test" 9 | test.test_files = FileList["test/**/*_test.rb"] 10 | test.verbose = true 11 | end 12 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # 2.0 2 | 3 | * make Coercible optional (include it to activate) 4 | * all options Uber:::Value with :method support 5 | 6 | 7 | 8 | # NOTES 9 | * use the same test setup everywhere (album -> songs -> composer) 10 | * copy things in tests 11 | * one test file per "feature": sync_test, sync_option_test. 12 | 13 | * fields is a Twin and sorts out all the changed? stuff. 14 | * virtual: don't read dont write 15 | * empty dont read, but write 16 | * read_only: read, don't write 17 | 18 | * make SkipUnchanged default? 19 | 20 | 21 | * `validates :title, :presence => true` 22 | with @model.title == "Little Green Car" and validate({}) the form is still valid (as we "have" a valid title). is that what we want? 23 | 24 | * document Form#to_hash and Form#to_nested_hash (e.g. with OpenStruct composition to make it a very simple form) 25 | * document getter: and representer_exec: 26 | 27 | * Debug module that logs every step. 28 | * no setters in Contract#setup 29 | 30 | vererben in inline representern (module zum einmixen, attrs löschen) 31 | 32 | # TODO: remove the concept of Errors#messages and just iterate over Errors. 33 | # each form contains its local field errors in Errors 34 | # form.messages should then go through them and compile a "summary" instead of adding them to the parents #errors in #validate. 35 | 36 | 37 | 38 | in a perfect world, a UI form would send JSON as in the API. that's why the reform form creates the correct object graph first, then validates. creating the graph usually happens in the API representer code. 39 | 40 | 41 | WHY DON'T PEOPLE USE THIS: 42 | http://guides.rubyonrails.org/association_basics.html#the-has-many-association 43 | 4.2.2.2 :autosave 44 | 45 | If you set the :autosave option to true, Rails will save any loaded members and destroy members that are marked for destruction whenever you save the parent object. -------------------------------------------------------------------------------- /lib/reform.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | end 3 | 4 | require "disposable" 5 | require "reform/contract" 6 | require "reform/form" 7 | require "reform/form/composition" 8 | require "reform/form/module" 9 | require "reform/errors" # TODO: remove in Reform 3. 10 | -------------------------------------------------------------------------------- /lib/reform/contract.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | # Define your form structure and its validations. Instantiate it with a model, 3 | # and then +validate+ this object graph. 4 | class Contract < Disposable::Twin 5 | require "reform/contract/custom_error" 6 | require "disposable/twin/composition" # Expose. 7 | include Expose 8 | 9 | feature Setup 10 | feature Setup::SkipSetter 11 | feature Default 12 | 13 | def self.default_nested_class 14 | Contract 15 | end 16 | 17 | def self.property(name, options = {}, &block) 18 | if twin = options.delete(:form) 19 | options[:twin] = twin 20 | end 21 | 22 | if validates_options = options[:validates] 23 | validates name, validates_options 24 | end 25 | 26 | super 27 | end 28 | 29 | def self.properties(*args) 30 | options = args.last.is_a?(Hash) ? args.pop : {} 31 | args.each { |name| property(name, options.dup) } 32 | end 33 | 34 | require "reform/result" 35 | require "reform/contract/validate" 36 | include Reform::Contract::Validate 37 | 38 | require "reform/validation" 39 | include Reform::Validation # ::validates and #valid? 40 | 41 | # FIXME: this is only for #to_nested_hash, #sync shouldn't be part of Contract. 42 | require "disposable/twin/sync" 43 | include Disposable::Twin::Sync 44 | 45 | private 46 | 47 | # DISCUSS: separate file? 48 | module Readonly 49 | def readonly?(name) 50 | options_for(name)[:writeable] == false 51 | end 52 | 53 | def options_for(name) 54 | self.class.options_for(name) 55 | end 56 | end 57 | 58 | def self.options_for(name) 59 | definitions.get(name) 60 | end 61 | include Readonly 62 | 63 | def self.clone # TODO: test. THIS IS ONLY FOR Trailblazer when contract gets cloned in suboperation. 64 | Class.new(self) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/reform/contract/custom_error.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | class Contract < Disposable::Twin 3 | # a "fake" Dry schema object to add into the @results array 4 | # super ugly hack required for 2.3.x version since we are creating 5 | # a new Reform::Errors instance every time we call form.errors 6 | class CustomError 7 | def initialize(key, error_text, results) 8 | @key = key 9 | @error_text = error_text 10 | @errors = {key => Array(error_text)} 11 | @messages = @errors 12 | @hint = {} 13 | @results = results 14 | 15 | merge! 16 | end 17 | 18 | attr_reader :messages, :hint 19 | 20 | def success? 21 | false 22 | end 23 | 24 | def failure? 25 | true 26 | end 27 | 28 | # dry 1.x errors method has 1 kwargs argument 29 | def errors(**_args) 30 | @errors 31 | end 32 | 33 | def merge! 34 | # to_h required for dry_v 1.x since the errors are Dry object instead of an hash 35 | @results.map(&:errors) 36 | .detect { |hash| hash.to_h.key?(@key) } 37 | .tap { |hash| hash.nil? ? @results << self : hash.to_h[@key] |= Array(@error_text) } 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/reform/contract/validate.rb: -------------------------------------------------------------------------------- 1 | class Reform::Contract < Disposable::Twin 2 | module Validate 3 | def initialize(*) 4 | # this will be removed in Reform 3.0. we need this for the presenting form, form builders 5 | # call the Form#errors method before validation. 6 | super 7 | @result = Result.new([]) 8 | end 9 | 10 | def validate 11 | validate!(nil).success? 12 | end 13 | 14 | # The #errors method will be removed in Reform 3.0 core. 15 | def errors(*args) 16 | Result::Errors.new(@result, self) 17 | end 18 | 19 | #:private: 20 | # only used in tests so far. this will be the new API in #call, where you will get @result. 21 | def to_result 22 | @result 23 | end 24 | 25 | def custom_errors 26 | @result.to_results.select { |result| result.is_a? Reform::Contract::CustomError } 27 | end 28 | 29 | def validate!(name, pointers = []) 30 | # run local validations. this could be nested schemas, too. 31 | local_errors_by_group = Reform::Validation::Groups::Validate.(self.class.validation_groups, self).compact # TODO: discss compact 32 | 33 | # blindly add injected pointers. will be readable via #errors. 34 | # also, add pointers from local errors here. 35 | pointers_for_nested = pointers + local_errors_by_group.collect { |errs| Result::Pointer.new(errs, []) }.compact 36 | 37 | nested_errors = validate_nested!(pointers_for_nested) 38 | 39 | # Result: unified interface #success?, #messages, etc. 40 | @result = Result.new(custom_errors + local_errors_by_group + pointers, nested_errors) 41 | end 42 | 43 | private 44 | 45 | # Recursively call validate! on nested forms. 46 | # A pointer keeps an entire result object (e.g. Dry result) and 47 | # the relevant path to its fragment, e.g. 48 | def validate_nested!(pointers) 49 | arr = [] 50 | 51 | schema.each(twin: true) do |dfn| 52 | # on collections, this calls validate! on each item form. 53 | Disposable::Twin::PropertyProcessor.new(dfn, self).() do |form, i| 54 | nested_pointers = pointers.collect { |pointer| pointer.advance(dfn[:name].to_sym, i) }.compact # pointer contains fragment for us, so go deeper 55 | 56 | arr << form.validate!(dfn[:name], nested_pointers) 57 | end 58 | end 59 | 60 | arr 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/reform/errors.rb: -------------------------------------------------------------------------------- 1 | # Provides the old API for Rails and friends. 2 | # Note that this might become an optional "deprecation" gem in Reform 3. 3 | class Reform::Contract::Result::Errors 4 | def initialize(result, form) 5 | @result = result # DISCUSS: we don't use this ATM? 6 | @form = form 7 | @dotted_errors = {} # Reform does not endorse this style of error msgs. 8 | 9 | DottedErrors.(@form, [], @dotted_errors) 10 | end 11 | 12 | # PROTOTYPING. THIS WILL GO TO A SEPARATE GEM IN REFORM 2.4/3.0. 13 | DottedErrors = ->(form, prefix, hash) do 14 | result = form.to_result 15 | result.messages.collect { |k, v| hash[[*prefix, k].join(".").to_sym] = v } 16 | 17 | form.schema.each(twin: true) do |dfn| 18 | Disposable::Twin::PropertyProcessor.new(dfn, form).() do |frm, i| 19 | form_obj = i ? form.send(dfn[:name])[i] : form.send(dfn[:name]) 20 | DottedErrors.(form_obj, [*prefix, dfn[:name]], hash) 21 | end 22 | end 23 | end 24 | 25 | def messages(*args) 26 | @dotted_errors 27 | end 28 | 29 | def full_messages 30 | @dotted_errors.collect { |path, errors| 31 | human_field = path.to_s.gsub(/([\.\_])+/, " ").gsub(/(\b\w)+/) { |s| s.capitalize } 32 | errors.collect { |message| "#{human_field} #{message}" } 33 | }.flatten 34 | end 35 | 36 | def [](name) 37 | @dotted_errors[name] || [] 38 | end 39 | 40 | def size 41 | messages.size 42 | end 43 | 44 | # needed for rails form helpers 45 | def empty? 46 | messages.empty? 47 | end 48 | 49 | # we need to delegate adding error to result because every time we call form.errors 50 | # a new instance of this class is created so we need to update the @results array 51 | # to be able to add custom errors here. 52 | # This method will actually work only AFTER a validate call has been made 53 | def add(key, error_test) 54 | @result.add_error(key, error_test) 55 | end 56 | end 57 | 58 | # Ensure that we can return Active Record compliant full messages when using dry 59 | # we only want unique messages in our array 60 | # 61 | # @full_errors.add() 62 | -------------------------------------------------------------------------------- /lib/reform/form.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | class Form < Contract 3 | class InvalidOptionsCombinationError < StandardError; end 4 | 5 | def self.default_nested_class 6 | Form 7 | end 8 | 9 | require "reform/form/validate" 10 | include Validate # override Contract#validate with additional behaviour. 11 | 12 | require "reform/form/populator" 13 | 14 | # called after populator: form.deserialize(params) 15 | # as this only included in the typed pipeline, it's not applied for scalars. 16 | Deserialize = ->(input, options) { input.deserialize(options[:fragment]) } # TODO: (result:, fragment:, **o) once we drop 2.0. 17 | 18 | module Property 19 | # Add macro logic, e.g. for :populator. 20 | # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity 21 | def property(name, options = {}, &block) 22 | if (options.keys & %i[skip_if populator]).size == 2 23 | raise InvalidOptionsCombinationError.new( 24 | "[Reform] #{self}:property:#{name} Do not use skip_if and populator together, use populator with skip! instead" 25 | ) 26 | end 27 | 28 | # if composition and inherited we also need this setting 29 | # to correctly inherit modules 30 | options[:_inherited] = options[:inherit] if options.key?(:on) && options.key?(:inherit) 31 | 32 | if options.key?(:parse) 33 | options[:deserializer] ||= {} 34 | options[:deserializer][:writeable] = options.delete(:parse) 35 | end 36 | 37 | options[:writeable] ||= options.delete(:writable) if options.key?(:writable) 38 | 39 | # for virtual collection we need at least to have the collection equal to [] to 40 | # avoid issue when the populator 41 | if (options.keys & %i[collection virtual]).size == 2 42 | options = { default: [] }.merge(options) 43 | end 44 | 45 | definition = super # letdisposable and declarative gems sort out inheriting of properties, and so on. 46 | definition.merge!(deserializer: {}) unless definition[:deserializer] # always keep :deserializer per property. 47 | 48 | deserializer_options = definition[:deserializer] 49 | 50 | # Populators 51 | internal_populator = Populator::Sync.new(nil) 52 | if block = definition[:populate_if_empty] 53 | internal_populator = Populator::IfEmpty.new(block) 54 | end 55 | if block = definition[:populator] # populator wins over populate_if_empty when :inherit 56 | internal_populator = Populator.new(block) 57 | end 58 | definition.merge!(internal_populator: internal_populator) unless options[:internal_populator] 59 | external_populator = Populator::External.new 60 | 61 | # always compute a parse_pipeline for each property of the deserializer and inject it via :parse_pipeline. 62 | # first, letrepresentable compute the pipeline functions by invoking #parse_functions. 63 | if definition[:nested] 64 | parse_pipeline = ->(input, opts) do 65 | functions = opts[:binding].send(:parse_functions) 66 | pipeline = Representable::Pipeline[*functions] # Pipeline[StopOnExcluded, AssignName, ReadFragment, StopOnNotFound, OverwriteOnNil, Collect[#, #, Deserialize], Set] 67 | 68 | pipeline = Representable::Pipeline::Insert.(pipeline, external_populator, replace: Representable::CreateObject::Instance) 69 | pipeline = Representable::Pipeline::Insert.(pipeline, Representable::Decorate, delete: true) 70 | pipeline = Representable::Pipeline::Insert.(pipeline, Deserialize, replace: Representable::Deserialize) 71 | pipeline = Representable::Pipeline::Insert.(pipeline, Representable::SetValue, delete: true) # FIXME: only diff to options without :populator 72 | end 73 | else 74 | parse_pipeline = ->(input, opts) do 75 | functions = opts[:binding].send(:parse_functions) 76 | pipeline = Representable::Pipeline[*functions] # Pipeline[StopOnExcluded, AssignName, ReadFragment, StopOnNotFound, OverwriteOnNil, Collect[#, #, Deserialize], Set] 77 | 78 | # FIXME: this won't work with property :name, inherit: true (where there is a populator set already). 79 | pipeline = Representable::Pipeline::Insert.(pipeline, external_populator, replace: Representable::SetValue) if definition[:populator] # FIXME: only diff to options without :populator 80 | pipeline 81 | end 82 | end 83 | 84 | deserializer_options[:parse_pipeline] ||= parse_pipeline 85 | 86 | if proc = definition[:skip_if] 87 | proc = Reform::Form::Validate::Skip::AllBlank.new if proc == :all_blank 88 | deserializer_options.merge!(skip_parse: proc) # TODO: same with skip_parse ==> External 89 | end 90 | 91 | # per default, everything should be writeable for the deserializer (we're only writing on the form). however, allow turning it off. 92 | deserializer_options.merge!(writeable: true) unless deserializer_options.key?(:writeable) 93 | 94 | definition 95 | end 96 | # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity 97 | end 98 | extend Property 99 | 100 | require "disposable/twin/changed" 101 | feature Disposable::Twin::Changed 102 | 103 | require "disposable/twin/sync" 104 | feature Disposable::Twin::Sync 105 | feature Disposable::Twin::Sync::SkipGetter 106 | 107 | require "disposable/twin/save" 108 | feature Disposable::Twin::Save 109 | 110 | require "reform/form/prepopulate" 111 | include Prepopulate 112 | 113 | def skip! 114 | Representable::Pipeline::Stop 115 | end 116 | 117 | require "reform/form/call" 118 | include Call 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/reform/form/call.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::Call 2 | def call(params, &block) 3 | bool = validate(params, &block) 4 | 5 | Result.new(bool, self) 6 | end 7 | 8 | # TODO: the result object might soon come from dry. 9 | class Result < SimpleDelegator 10 | def initialize(success, data) 11 | @success = success 12 | super(data) 13 | end 14 | 15 | def success? 16 | @success 17 | end 18 | 19 | def failure? 20 | !@success 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/reform/form/coercion.rb: -------------------------------------------------------------------------------- 1 | require "disposable/twin/coercion" 2 | 3 | Reform::Form::Coercion = Disposable::Twin::Coercion 4 | -------------------------------------------------------------------------------- /lib/reform/form/composition.rb: -------------------------------------------------------------------------------- 1 | require "disposable/twin/composition" 2 | 3 | module Reform::Form::Composition 4 | # Automatically creates a Composition object for you when initializing the form. 5 | def self.included(base) 6 | base.class_eval do 7 | # extend Reform::Form::ActiveModel::ClassMethods # ::model. 8 | extend ClassMethods 9 | include Disposable::Twin::Composition 10 | end 11 | end 12 | 13 | module ClassMethods 14 | # Same as ActiveModel::model but allows you to define the main model in the composition 15 | # using +:on+. 16 | # 17 | # class CoverSongForm < Reform::Form 18 | # model :song, on: :cover_song 19 | def model(main_model, options = {}) 20 | super 21 | 22 | composition_model = options[:on] || main_model 23 | 24 | # FIXME: this should just delegate to :model as in FB, and the comp would take care of it internally. 25 | %i[persisted? to_key to_param].each do |method| 26 | define_method method do 27 | model[composition_model].send(method) 28 | end 29 | end 30 | 31 | self 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/reform/form/dry.rb: -------------------------------------------------------------------------------- 1 | gem 'dry-validation', '~> 1.5' 2 | require "dry-validation" 3 | require "reform/validation" 4 | require "reform/form/dry/input_hash" 5 | 6 | ::Dry::Validation.load_extensions(:hints) 7 | 8 | module Reform::Form::Dry 9 | class Contract < Dry::Validation::Contract 10 | end 11 | 12 | def self.included(includer) 13 | includer.send :include, Validations 14 | includer.extend Validations::ClassMethods 15 | end 16 | 17 | module Validations 18 | module ClassMethods 19 | def validation_group_class 20 | Group 21 | end 22 | end 23 | 24 | def self.included(includer) 25 | includer.extend(ClassMethods) 26 | end 27 | 28 | class Group 29 | include InputHash 30 | 31 | def initialize(options) 32 | @validator = options.fetch(:contract, Contract) 33 | @schema_inject_params = options.fetch(:with, {}) 34 | end 35 | 36 | attr_reader :validator, :schema_inject_params, :block 37 | 38 | def instance_exec(&block) 39 | @block = block 40 | end 41 | 42 | def call(form) 43 | # when passing options[:schema] the class instance is already created so we just need to call 44 | # "call" 45 | return validator.call(input_hash(form)) unless validator.is_a?(Class) && @validator <= ::Dry::Validation::Contract 46 | 47 | dynamic_options = { form: form } 48 | inject_options = schema_inject_params.merge(dynamic_options) 49 | contract.new(**inject_options).call(input_hash(form)) 50 | end 51 | 52 | def contract 53 | @contract ||= Class.new(validator, &block) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/reform/form/dry/input_hash.rb: -------------------------------------------------------------------------------- 1 | module Reform::Form::Dry 2 | module InputHash 3 | private 4 | 5 | # if dry_error is a hash rather than an array then it contains 6 | # the messages for a nested property 7 | # these messages need to be added to the correct collection 8 | # objects. 9 | 10 | # collections: 11 | # {0=>{:name=>["must be filled"]}, 1=>{:name=>["must be filled"]}} 12 | 13 | # Objects: 14 | # {:name=>["must be filled"]} 15 | # simply load up the object and attach the message to it 16 | 17 | # we can't use to_nested_hash as it get's messed up by composition. 18 | def input_hash(form) 19 | hash = form.class.nested_hash_representer.new(form).to_hash 20 | symbolize_hash(hash) 21 | end 22 | 23 | # dry-v needs symbolized keys 24 | # TODO: Don't do this here... Representers?? 25 | def symbolize_hash(old_hash) 26 | old_hash.each_with_object({}) do |(k, v), new_hash| 27 | new_hash[k.to_sym] = if v.is_a?(Hash) 28 | symbolize_hash(v) 29 | elsif v.is_a?(Array) 30 | v.map { |h| h.is_a?(Hash) ? symbolize_hash(h) : h } 31 | else 32 | v 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/reform/form/module.rb: -------------------------------------------------------------------------------- 1 | # Include this in every module that gets further included. 2 | module Reform::Form::Module 3 | # DISCUSS: could this be part of Declarative? 4 | def self.included(base) 5 | base.extend ClassMethods 6 | base.extend Declarative::Heritage::DSL # ::heritage 7 | # base.extend Declarative::Heritage::Included # ::included 8 | base.extend Included 9 | end 10 | 11 | module Included 12 | # Gets imported into your module and will be run when including it. 13 | def included(includer) 14 | super 15 | # first, replay all declaratives like ::property on includer. 16 | heritage.(includer) # this normally happens via Heritage::Included. 17 | # then, include optional accessors. 18 | includer.send(:include, self::InstanceMethods) if const_defined?(:InstanceMethods) 19 | end 20 | end 21 | 22 | module ClassMethods 23 | def method_missing(method, *args, &block) 24 | heritage.record(method, *args, &block) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/reform/form/populator.rb: -------------------------------------------------------------------------------- 1 | # Implements the :populator option. 2 | # 3 | # populator: -> (fragment:, model:, :binding) 4 | # populator: -> (fragment:, collection:, index:, binding:) 5 | # 6 | # For collections, the entire collection and the currently deserialised index is passed in. 7 | class Reform::Form::Populator 8 | def initialize(user_proc) 9 | @user_proc = user_proc # the actual `populator: ->{}` block from the user, via ::property. 10 | @value = ::Representable::Option(user_proc) # we can now process Callable, procs, :symbol. 11 | end 12 | 13 | def call(input, options) 14 | model = get(options) 15 | twin = call!(options.merge(model: model, collection: model)) 16 | 17 | return twin if twin == Representable::Pipeline::Stop 18 | 19 | # this kinda sucks. the proc may call self.composer = Artist.new, but there's no way we can 20 | # return the twin instead of the model from the #composer= setter. 21 | twin = get(options) unless options[:binding].array? 22 | 23 | # we always need to return a twin/form here so we can call nested.deserialize(). 24 | handle_fail(twin, options) 25 | 26 | twin 27 | end 28 | 29 | private 30 | 31 | def call!(options) 32 | form = options[:represented] 33 | evaluate_option(form, options) 34 | end 35 | 36 | def evaluate_option(form, options) 37 | if @user_proc.is_a?(Uber::Callable) && @user_proc.method(:call).arity == 2 # def call(form, options) 38 | warn %{[Reform] Accepting `form` as a positional argument in `:populator` will be deprecated. Please use `def call(form:, **options)` signature instead.} 39 | 40 | return @value.(form, exec_context: form, keyword_arguments: options) 41 | end 42 | 43 | @value.(exec_context: form, keyword_arguments: options.merge(form: form)) # Representable::Option call 44 | end 45 | 46 | def handle_fail(twin, options) 47 | raise "[Reform] Your :populator did not return a Reform::Form instance for `#{options[:binding].name}`." if options[:binding][:nested] && !twin.is_a?(Reform::Form) 48 | end 49 | 50 | def get(options) 51 | Representable::GetValue.(nil, options) 52 | end 53 | 54 | class IfEmpty < self # Populator 55 | def call!(options) 56 | binding, twin, index, fragment = options[:binding], options[:model], options[:index], options[:fragment] # TODO: remove once we drop 2.0. 57 | form = options[:represented] 58 | 59 | if binding.array? 60 | item = twin.original[index] and return item 61 | 62 | new_index = [index, twin.count].min # prevents nil items with initially empty/smaller collections and :skip_if's. 63 | # this means the fragment index and populated nested form index might be different. 64 | 65 | twin.insert(new_index, run!(form, fragment, options)) # form.songs.insert(Song.new) 66 | else 67 | return if twin 68 | 69 | form.send(binding.setter, run!(form, fragment, options)) # form.artist=(Artist.new) 70 | end 71 | end 72 | 73 | private 74 | 75 | def run!(form, fragment, options) 76 | return @user_proc.new if @user_proc.is_a?(Class) # handle populate_if_empty: Class. this excludes using Callables, though. 77 | 78 | deprecate_positional_args(form, @user_proc, options) do 79 | evaluate_option(form, options) 80 | end 81 | end 82 | 83 | def deprecate_positional_args(form, proc, options) # TODO: remove in 2.2. 84 | arity = proc.is_a?(Symbol) ? form.method(proc).arity : proc.arity 85 | return yield if arity == 1 86 | warn "[Reform] Positional arguments for :prepopulate and friends are deprecated. Please use ->(options) and enjoy the rest of your day. Learn more at http://trailblazerb.org/gems/reform/upgrading-guide.html#to-21" 87 | 88 | @value.(form, options[:fragment], options[:user_options]) 89 | end 90 | end 91 | 92 | # Sync (default) blindly grabs the corresponding form twin and returns it. This might imply that nil is returned, 93 | # and in turn #validate! is called on nil. 94 | class Sync < self 95 | def call!(options) 96 | return options[:model][options[:index]] if options[:binding].array? 97 | options[:model] 98 | end 99 | end 100 | 101 | # This function is added to the deserializer's pipeline. 102 | # 103 | # When deserializing, the representer will call this function and thereby delegate the 104 | # entire population process to the form. The form's :internal_populator will run its 105 | # :populator option function and return the new/existing form instance. 106 | # The deserializing representer will then continue on that returned form. 107 | # 108 | # Goal of this indirection is to leave all population logic in the form, while the 109 | # representer really just traverses an incoming document and dispatches business logic 110 | # (which population is) to the form. 111 | class External 112 | def call(input, options) 113 | options[:represented].class.definitions 114 | .get(options[:binding][:name])[:internal_populator].(input, options) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/reform/form/prepopulate.rb: -------------------------------------------------------------------------------- 1 | # prepopulate!(options) 2 | # prepopulator: ->(model, user_options) 3 | module Reform::Form::Prepopulate 4 | def prepopulate!(options = {}) 5 | prepopulate_local!(options) # call #prepopulate! on local properties. 6 | prepopulate_nested!(options) # THEN call #prepopulate! on nested forms. 7 | 8 | self 9 | end 10 | 11 | private 12 | 13 | def prepopulate_local!(options) 14 | schema.each do |dfn| 15 | next unless block = dfn[:prepopulator] 16 | ::Representable::Option(block).(exec_context: self, keyword_arguments: options) 17 | end 18 | end 19 | 20 | def prepopulate_nested!(options) 21 | schema.each(twin: true) do |dfn| 22 | Disposable::Twin::PropertyProcessor.new(dfn, self).() { |form| form.prepopulate!(options) } 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/reform/form/validate.rb: -------------------------------------------------------------------------------- 1 | # Mechanics for writing to forms in #validate. 2 | module Reform::Form::Validate 3 | module Skip 4 | class AllBlank 5 | include Uber::Callable 6 | 7 | def call(input:, binding:, **) 8 | # TODO: Schema should provide property names as plain list. 9 | # ensure param keys are strings. 10 | params = input.each_with_object({}) { |(k, v), hash| 11 | hash[k.to_s] = v 12 | } 13 | 14 | # return false if any property inputs are populated. 15 | binding[:nested].definitions.each do |definition| 16 | value = params[definition.name.to_s] 17 | return false if (!value.nil? && value != '') 18 | end 19 | 20 | true # skip this property 21 | end 22 | end 23 | end 24 | 25 | def validate(params) 26 | # allow an external deserializer. 27 | @input_params = params # we want to store these for access via dry later 28 | block_given? ? yield(params) : deserialize(params) 29 | 30 | super() # run the actual validation on self. 31 | end 32 | attr_reader :input_params # make the raw input params public 33 | 34 | def deserialize(params) 35 | params = deserialize!(params) 36 | deserializer.new(self).from_hash(params) 37 | end 38 | 39 | private 40 | 41 | # Meant to return params processable by the representer. This is the hook for munching date fields, etc. 42 | def deserialize!(params) 43 | # NOTE: it is completely up to the form user how they want to deserialize (e.g. using an external JSON-API representer). 44 | # use the deserializer as an external instance to operate on the Twin API, 45 | # e.g. adding new items in collections using #<< etc. 46 | # DISCUSS: using self here will call the form's setters like title= which might be overridden. 47 | params 48 | end 49 | 50 | # Default deserializer for hash. 51 | # This is input-specific, e.g. Hash, JSON, or XML. 52 | def deserializer!(source = self.class, options = {}) # called on top-level, only, for now. 53 | deserializer = Disposable::Rescheme.from( 54 | source, 55 | { 56 | include: [Representable::Hash::AllowSymbols, Representable::Hash], 57 | superclass: Representable::Decorator, 58 | definitions_from: ->(inline) { inline.definitions }, 59 | options_from: :deserializer, 60 | exclude_options: %i[default populator] # Reform must not copy Disposable/Reform-only options that might confuse representable. 61 | }.merge(options) 62 | ) 63 | 64 | deserializer 65 | end 66 | 67 | def deserializer(*args) 68 | # DISCUSS: should we simply delegate to class and sort out memoizing there? 69 | self.class.deserializer_class || self.class.deserializer_class = deserializer!(*args) 70 | end 71 | 72 | def self.included(includer) 73 | includer.singleton_class.send :attr_accessor, :deserializer_class 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/reform/result.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | class Contract < Disposable::Twin 3 | # Collects all native results of a form of all groups and provides 4 | # a unified API: #success?, #errors, #messages, #hints. 5 | # #success? returns validity of the branch. 6 | class Result 7 | def initialize(results, nested_results = []) # DISCUSS: do we like this? 8 | @results = results # native Result objects, e.g. `#"Fallout", :composer=>nil} errors={}>` 9 | @failure = (results + nested_results).find(&:failure?) # TODO: test nested. 10 | end 11 | 12 | def failure?; @failure end 13 | 14 | def success?; !failure? end 15 | 16 | def errors(*args); filter_for(:errors, *args) end 17 | 18 | def messages(*args); filter_for(:messages, *args) end 19 | 20 | def hints(*args); filter_for(:hints, *args) end 21 | 22 | def add_error(key, error_text) 23 | CustomError.new(key, error_text, @results) 24 | end 25 | 26 | def to_results 27 | @results 28 | end 29 | 30 | private 31 | 32 | # this doesn't do nested errors (e.g. ) 33 | def filter_for(method, *args) 34 | @results.collect { |r| r.public_send(method, *args).to_h } 35 | .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)) } } 36 | .find_all { |k, v| # filter :nested=>{:something=>["too nested!"]} #DISCUSS: do we want that here? 37 | if v.is_a?(Hash) 38 | nested_errors = v.select { |attr_key, val| attr_key.is_a?(Integer) && val.is_a?(Array) && val.any? } 39 | v = nested_errors.to_a if nested_errors.any? 40 | end 41 | v.is_a?(Array) 42 | }.to_h 43 | end 44 | 45 | # Note: this class will be redundant in Reform 3, where the public API 46 | # allows/enforces to pass options to #errors (e.g. errors(locale: "br")) 47 | # which means we don't have to "lazy-handle" that with "pointers". 48 | # :private: 49 | class Pointer 50 | extend Forwardable 51 | 52 | def initialize(result, path) 53 | @result, @path = result, path 54 | end 55 | 56 | def_delegators :@result, :success?, :failure? 57 | 58 | def errors(*args); traverse_for(:errors, *args) end 59 | 60 | def messages(*args); traverse_for(:messages, *args) end 61 | 62 | def hints(*args); traverse_for(:hints, *args) end 63 | 64 | def advance(*path) 65 | path = @path + path.compact # remove index if nil. 66 | traverse = traverse(@result.errors, path) 67 | # when returns {} is because no errors are found 68 | # when returns a String is because an error has been found on the main key not in the nested one. 69 | # Collection with custom rule will return a String here and does not need to be considered 70 | # as a nested error. 71 | # when return an Array without an index is same as String but it's a property with a custom rule. 72 | # Check test/validation/dry_validation_test.rb:685 73 | return if traverse == {} || traverse.is_a?(String) || (traverse.is_a?(Array) && path.compact.size == 1) 74 | 75 | Pointer.new(@result, path) 76 | end 77 | 78 | private 79 | 80 | def traverse(hash, path) 81 | path.inject(hash) { |errs, segment| errs[segment] || {} } # FIXME. test if all segments present. 82 | end 83 | 84 | def traverse_for(method, *args) 85 | traverse(@result.public_send(method, *args), @path) # TODO: return [] if nil 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/reform/validation.rb: -------------------------------------------------------------------------------- 1 | # Adds ::validates and friends, and #valid? to the object. 2 | # This is completely form-independent. 3 | module Reform::Validation 4 | module ClassMethods 5 | def validation_groups 6 | @groups ||= Groups.new(validation_group_class) 7 | end 8 | 9 | # DSL. 10 | def validation(name = nil, options = {}, &block) 11 | options = deprecate_validation_positional_args(name, options) 12 | name = options[:name] # TODO: remove in favor of kw args in 3.0. 13 | 14 | heritage.record(:validation, options, &block) 15 | group = validation_groups.add(name, options) 16 | 17 | group.instance_exec(&block) 18 | end 19 | 20 | def deprecate_validation_positional_args(name, options) 21 | if name.is_a?(Symbol) 22 | warn "[Reform] Form::validation API is now: validation(name: :default, if:nil, schema:Schema). Please use keyword arguments instead of positional arguments." 23 | return {name: name}.merge(options) 24 | end 25 | 26 | return {name: :default}.merge(options) if name.nil? 27 | 28 | {name: :default}.merge(name) 29 | end 30 | 31 | def validation_group_class 32 | raise NoValidationLibraryError, "no validation library loaded. Please include a " + 33 | "validation library such as Reform::Form::Dry" 34 | end 35 | end 36 | 37 | def self.included(includer) 38 | includer.extend(ClassMethods) 39 | end 40 | 41 | def valid? 42 | validate({}) 43 | end 44 | 45 | NoValidationLibraryError = Class.new(RuntimeError) 46 | end 47 | 48 | require "reform/validation/groups" 49 | -------------------------------------------------------------------------------- /lib/reform/validation/groups.rb: -------------------------------------------------------------------------------- 1 | module Reform::Validation 2 | # A Group is a set of native validations, targeting a validation backend (AM, Lotus, Dry). 3 | # Group receives configuration via #validates and #validate and translates that to its 4 | # internal backend. 5 | # 6 | # The #call method will run those validations on the provided objects. 7 | 8 | # Set of Validation::Group objects. 9 | # This implements adding, iterating, and finding groups, including "inheritance" and insertions. 10 | class Groups < Array 11 | def initialize(group_class) 12 | @group_class = group_class 13 | end 14 | 15 | def add(name, options) 16 | if options[:inherit] 17 | return self[name] if self[name] 18 | end 19 | 20 | i = index_for(options) 21 | 22 | self.insert(i, [name, group = @group_class.new(options), options]) # Group.new 23 | group 24 | end 25 | 26 | private 27 | 28 | def index_for(options) 29 | return find_index { |el| el.first == options[:after] } + 1 if options[:after] 30 | size # default index: append. 31 | end 32 | 33 | def [](name) 34 | cfg = find { |c| c.first == name } 35 | return unless cfg 36 | cfg[1] 37 | end 38 | 39 | # Runs all validations groups according to their rules and returns all Result objects. 40 | class Validate 41 | def self.call(groups, form) 42 | results = {} 43 | 44 | groups.collect do |(name, group, options)| 45 | next unless evaluate?(options[:if], results, form) 46 | results[name] = group.(form) # run validation for group. store and collect . 47 | end 48 | end 49 | 50 | def self.evaluate?(depends_on, results, form) 51 | return true if depends_on.nil? 52 | return !results[depends_on].nil? && results[depends_on].success? if depends_on.is_a?(Symbol) 53 | form.instance_exec(results, &depends_on) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/reform/version.rb: -------------------------------------------------------------------------------- 1 | module Reform 2 | VERSION = "2.6.2".freeze 3 | end 4 | -------------------------------------------------------------------------------- /reform.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "reform/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "reform" 7 | spec.version = Reform::VERSION 8 | spec.authors = ["Nick Sutterer", "Fran Worley"] 9 | spec.email = ["apotonick@gmail.com", "frances@safetytoolbox.co.uk"] 10 | spec.description = "Form object decoupled from models." 11 | spec.summary = "Form object decoupled from models with validation, population and presentation." 12 | spec.homepage = "https://github.com/trailblazer/reform" 13 | spec.license = "MIT" 14 | 15 | spec.files = `git ls-files`.split($/) 16 | spec.executables = spec.files.grep(%r(^bin/)) { |f| File.basename(f) } 17 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_dependency "disposable", ">= 0.5.0", "< 1.0.0" 21 | spec.add_dependency "representable", ">= 3.1.1", "< 4" 22 | spec.add_dependency "uber", "< 0.2.0" 23 | 24 | spec.add_development_dependency "bundler" 25 | spec.add_development_dependency "minitest" 26 | spec.add_development_dependency "minitest-line" 27 | spec.add_development_dependency "multi_json" 28 | spec.add_development_dependency "rake" 29 | end 30 | -------------------------------------------------------------------------------- /test/benchmarking.rb: -------------------------------------------------------------------------------- 1 | require "reform" 2 | require "benchmark/ips" 3 | require "reform/form/dry" 4 | 5 | class BandForm < Reform::Form 6 | feature Reform::Form::Dry 7 | property :name #, validates: {presence: true} 8 | collection :songs do 9 | property :title #, validates: {presence: true} 10 | end 11 | end 12 | 13 | class OptimizedBandForm < Reform::Form 14 | feature Reform::Form::Dry 15 | property :name #, validates: {presence: true} 16 | collection :songs do 17 | property :title #, validates: {presence: true} 18 | 19 | def deserializer(*args) 20 | # DISCUSS: should we simply delegate to class and sort out memoizing there? 21 | self.class.deserializer_class || self.class.deserializer_class = deserializer!(*args) 22 | end 23 | end 24 | 25 | def deserializer(*args) 26 | # DISCUSS: should we simply delegate to class and sort out memoizing there? 27 | self.class.deserializer_class || self.class.deserializer_class = deserializer!(*args) 28 | end 29 | end 30 | 31 | songs = 10.times.collect { OpenStruct.new(title: "Be Stag") } 32 | band = OpenStruct.new(name: "Teenage Bottlerock", songs: songs) 33 | 34 | unoptimized_form = BandForm.new(band) 35 | optimized_form = OptimizedBandForm.new(band) 36 | 37 | songs_params = songs_params = 10.times.collect { {title: "Commando"} } 38 | 39 | Benchmark.ips do |x| 40 | x.report("2.2") { BandForm.new(band).validate("name" => "Ramones", "songs" => songs_params) } 41 | x.report("2.3") { OptimizedBandForm.new(band).validate("name" => "Ramones", "songs" => songs_params) } 42 | end 43 | 44 | exit 45 | 46 | songs = 50.times.collect { OpenStruct.new(title: "Be Stag") } 47 | band = OpenStruct.new(name: "Teenage Bottlerock", songs: songs) 48 | 49 | songs_params = 50.times.collect { {title: "Commando"} } 50 | 51 | time = Benchmark.measure do 52 | 100.times.each do 53 | form = BandForm.new(band) 54 | form.validate("name" => "Ramones", "songs" => songs_params) 55 | form.save 56 | end 57 | end 58 | 59 | puts time 60 | -------------------------------------------------------------------------------- /test/call_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CallTest < Minitest::Spec 4 | Song = Struct.new(:title) 5 | 6 | class SongForm < TestForm 7 | property :title 8 | 9 | validation do 10 | params { required(:title).filled } 11 | end 12 | end 13 | 14 | let(:form) { SongForm.new(Song.new) } 15 | 16 | it { assert form.(title: "True North").success? } 17 | it { refute form.(title: "True North").failure? } 18 | it { refute form.(title: "").success? } 19 | it { assert form.(title: "").failure? } 20 | 21 | it { assert_equal form.(title: "True North").errors.messages, {} } 22 | it { assert_equal form.(title: "").errors.messages, title: ["must be filled"] } 23 | end 24 | -------------------------------------------------------------------------------- /test/changed_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "reform/form/coercion" 3 | 4 | class ChangedTest < MiniTest::Spec 5 | Song = Struct.new(:title, :album, :composer) 6 | Album = Struct.new(:name, :songs, :artist) 7 | Artist = Struct.new(:name) 8 | 9 | class AlbumForm < TestForm 10 | property :name 11 | 12 | collection :songs do 13 | property :title 14 | 15 | property :composer do 16 | property :name 17 | end 18 | end 19 | end 20 | 21 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 22 | let(:composer) { Artist.new("Greg Graffin") } 23 | let(:album) { Album.new("The Dissent Of Man", [song_with_composer]) } 24 | 25 | let(:form) { AlbumForm.new(album) } 26 | 27 | # nothing changed after setup. 28 | it do 29 | refute form.changed?(:name) 30 | refute form.songs[0].changed?(:title) 31 | refute form.songs[0].composer.changed?(:name) 32 | end 33 | 34 | # after validate, things might have changed. 35 | it do 36 | form.validate("name" => "Out Of Bounds", "songs" => [{"composer" => {"name" => "Ingemar Jansson & Mikael Danielsson"}}]) 37 | assert form.changed?(:name) 38 | refute form.songs[0].changed?(:title) 39 | assert form.songs[0].composer.changed?(:name) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/coercion_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "reform/form/coercion" 3 | require "disposable/twin/property/hash" 4 | 5 | class CoercionTest < BaseTest 6 | class Irreversible 7 | def self.call(value) 8 | value * 2 9 | end 10 | end 11 | 12 | class Form < TestForm 13 | feature Coercion 14 | include Disposable::Twin::Property::Hash 15 | 16 | property :released_at, type: Types::Params::DateTime 17 | 18 | property :hit do 19 | property :length, type: Types::Params::Integer 20 | property :good, type: Types::Params::Bool 21 | end 22 | 23 | property :band do 24 | property :label do 25 | property :value, type: Irreversible 26 | end 27 | end 28 | 29 | property :metadata, field: :hash do 30 | property :publication_settings do 31 | property :featured, type: Types::Params::Bool 32 | end 33 | end 34 | end 35 | 36 | subject do 37 | Form.new(album) 38 | end 39 | 40 | let(:album) do 41 | OpenStruct.new( 42 | released_at: "31/03/1981", 43 | hit: OpenStruct.new(length: "312"), 44 | band: Band.new(OpenStruct.new(value: "9999.99")), 45 | metadata: {} 46 | ) 47 | end 48 | 49 | # it { subject.released_at.must_be_kind_of DateTime } 50 | it { assert_equal subject.released_at, "31/03/1981" } # NO coercion in setup. 51 | it { assert_equal subject.hit.length, "312" } 52 | it { assert_equal subject.band.label.value, "9999.99" } 53 | 54 | let(:params) do 55 | { 56 | released_at: "30/03/1981", 57 | hit: { 58 | length: "312", 59 | good: "0", 60 | }, 61 | band: { 62 | label: { 63 | value: "9999.99" 64 | } 65 | }, 66 | metadata: { 67 | publication_settings: { 68 | featured: "0" 69 | } 70 | } 71 | } 72 | end 73 | 74 | # validate 75 | describe "#validate" do 76 | before { subject.validate(params) } 77 | 78 | it { assert_equal subject.released_at, DateTime.parse("30/03/1981") } 79 | it { assert_equal subject.hit.length, 312 } 80 | it { assert_equal subject.hit.good, false } 81 | it { assert_equal subject.band.label.value, "9999.999999.99" } # coercion happened once. 82 | it { assert_equal subject.metadata.publication_settings.featured, false } 83 | end 84 | 85 | # sync 86 | describe "#sync" do 87 | before do 88 | assert subject.validate(params) 89 | subject.sync 90 | end 91 | 92 | it { assert_equal album.released_at, DateTime.parse("30/03/1981") } 93 | it { assert_equal album.hit.length, 312 } 94 | it { assert_equal album.hit.good, false } 95 | it { assert_nil album.metadata[:publication_settings] } 96 | it { assert_equal album.metadata["publication_settings"]["featured"], false } 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/composition_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FormCompositionInheritanceTest < MiniTest::Spec 4 | module SizePrice 5 | include Reform::Form::Module 6 | 7 | property :price 8 | property :size 9 | 10 | module InstanceMethods 11 | def price(for_size: size) 12 | case for_size.to_sym 13 | when :s then super() * 1 14 | when :m then super() * 2 15 | when :l then super() * 3 16 | end 17 | end 18 | end 19 | end 20 | 21 | class OutfitForm < TestForm 22 | include Reform::Form::Composition 23 | include SizePrice 24 | 25 | property :price, inherit: true, on: :tshirt 26 | property :size, inherit: true, on: :measurement 27 | end 28 | 29 | let(:measurement) { Measurement.new(:l) } 30 | let(:tshirt) { Tshirt.new(2, :m) } 31 | let(:form) { OutfitForm.new(tshirt: tshirt, measurement: measurement) } 32 | 33 | Tshirt = Struct.new(:price, :size) 34 | Measurement = Struct.new(:size) 35 | 36 | it { assert_equal form.price, 6 } 37 | it { assert_equal form.price(for_size: :s), 2 } 38 | end 39 | 40 | class FormCompositionTest < MiniTest::Spec 41 | Song = Struct.new(:id, :title, :band) 42 | Requester = Struct.new(:id, :name, :requester) 43 | Band = Struct.new(:title) 44 | 45 | class RequestForm < TestForm 46 | include Composition 47 | 48 | property :name, on: :requester 49 | property :requester_id, on: :requester, from: :id 50 | properties :title, :id, on: :song 51 | # property :channel # FIXME: what about the "main model"? 52 | property :channel, virtual: true, on: :song 53 | property :requester, on: :requester 54 | property :captcha, on: :song, virtual: true 55 | 56 | validation do 57 | params do 58 | required(:name).filled 59 | required(:title).filled 60 | end 61 | end 62 | 63 | property :band, on: :song do 64 | property :title 65 | end 66 | end 67 | 68 | let(:form) { RequestForm.new(song: song, requester: requester) } 69 | let(:song) { Song.new(1, "Rio", band) } 70 | let(:requester) { Requester.new(2, "Duran Duran", "MCP") } 71 | let(:band) { Band.new("Duran^2") } 72 | 73 | # delegation form -> composition works 74 | it { assert_equal form.id, 1 } 75 | it { assert_equal form.title, "Rio" } 76 | it { assert_equal form.name, "Duran Duran" } 77 | it { assert_equal form.requester_id, 2 } 78 | it { assert_nil form.channel } 79 | it { assert_equal form.requester, "MCP" } # same name as composed model. 80 | it { assert_nil form.captcha } 81 | 82 | # #model just returns . 83 | it { assert form.mapper.is_a? Disposable::Composition } 84 | 85 | # #model[] -> composed models 86 | it { assert_equal form.model[:requester], requester } 87 | it { assert_equal form.model[:song], song } 88 | 89 | it "creates Composition for you" do 90 | assert_equal form.validate("title" => "Greyhound", "name" => "Frenzal Rhomb"), true 91 | assert_equal form.validate("title" => "", "name" => "Frenzal Rhomb"), false 92 | end 93 | 94 | describe "#save" do 95 | # #save with {} 96 | it do 97 | hash = {} 98 | 99 | form.save do |map| 100 | hash[:name] = form.name 101 | hash[:title] = form.title 102 | end 103 | 104 | assert_equal hash, name: "Duran Duran", title: "Rio" 105 | end 106 | 107 | it "provides nested symbolized hash as second block argument" do 108 | form.validate("title" => "Greyhound", "name" => "Frenzal Rhomb", "channel" => "JJJ", "captcha" => "wonderful") 109 | 110 | hash = nil 111 | 112 | form.save do |map| 113 | hash = map 114 | end 115 | 116 | assert_equal hash, { 117 | song: {"title" => "Greyhound", "id" => 1, "channel" => "JJJ", "captcha" => "wonderful", "band" => {"title" => "Duran^2"}}, 118 | requester: {"name" => "Frenzal Rhomb", "id" => 2, "requester" => "MCP"} 119 | } 120 | end 121 | 122 | it "xxx pushes data to models and calls #save when no block passed" do 123 | song.extend(Saveable) 124 | requester.extend(Saveable) 125 | band.extend(Saveable) 126 | 127 | form.validate("title" => "Greyhound", "name" => "Frenzal Rhomb", "captcha" => "1337") 128 | assert_equal form.captcha, "1337" # TODO: move to separate test. 129 | 130 | form.save 131 | 132 | assert_equal requester.name, "Frenzal Rhomb" 133 | assert_equal requester.saved?, true 134 | assert_equal song.title, "Greyhound" 135 | assert_equal song.saved?, true 136 | assert_equal song.band.title, "Duran^2" 137 | assert_equal song.band.saved?, true 138 | end 139 | 140 | it "returns true when models all save successfully" do 141 | song.extend(Saveable) 142 | requester.extend(Saveable) 143 | band.extend(Saveable) 144 | 145 | assert_equal form.save, true 146 | end 147 | 148 | it "returns false when one or more models don't save successfully" do 149 | module Unsaveable 150 | def save 151 | false 152 | end 153 | end 154 | 155 | song.extend(Unsaveable) 156 | requester.extend(Saveable) 157 | band.extend(Saveable) 158 | 159 | assert_equal form.save, false 160 | end 161 | end 162 | end 163 | 164 | class FormCompositionCollectionTest < MiniTest::Spec 165 | Book = Struct.new(:id, :name) 166 | Library = Struct.new(:id) do 167 | def books 168 | [Book.new(1, "My book")] 169 | end 170 | end 171 | 172 | class LibraryForm < TestForm 173 | include Reform::Form::Composition 174 | 175 | collection :books, on: :library do 176 | property :id 177 | property :name 178 | end 179 | end 180 | 181 | let(:form) { LibraryForm.new(library: library) } 182 | let(:library) { Library.new(2) } 183 | 184 | it { form.save { |hash| assert_equal hash, { library: { "books" => [{ "id" => 1, "name" => "My book" }] } } } } 185 | end 186 | -------------------------------------------------------------------------------- /test/contract/custom_error_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CustomerErrorTest < MiniTest::Spec 4 | let(:key) { :name } 5 | let(:error_text) { "text2" } 6 | let(:starting_error) { [OpenStruct.new(errors: {title: ["text1"]})] } 7 | 8 | let(:custom_error) { Reform::Contract::CustomError.new(key, error_text, @results) } 9 | 10 | before { @results = starting_error } 11 | 12 | it "base class structure" do 13 | assert_equal custom_error.success?, false 14 | assert_equal custom_error.failure?, true 15 | assert_equal custom_error.errors, key => [error_text] 16 | assert_equal custom_error.messages, key => [error_text] 17 | assert_equal custom_error.hint, {} 18 | end 19 | 20 | describe "updates @results accordingly" do 21 | it "add new key" do 22 | custom_error 23 | 24 | assert_equal @results.size, 2 25 | errors = @results.map(&:errors) 26 | 27 | assert_equal errors[0], starting_error.first.errors 28 | assert_equal errors[1], custom_error.errors 29 | end 30 | 31 | describe "when key error already exists in @results" do 32 | let(:key) { :title } 33 | 34 | it "merge errors text" do 35 | custom_error 36 | 37 | assert_equal @results.size, 1 38 | 39 | assert_equal @results.first.errors.values, [%w[text1 text2]] 40 | end 41 | 42 | describe "add error text is already" do 43 | let(:error_text) { "text1" } 44 | 45 | it 'does not create duplicates' do 46 | custom_error 47 | 48 | assert_equal @results.size, 1 49 | 50 | assert_equal @results.first.errors.values, [%w[text1]] 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/contract_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ContractTest < MiniTest::Spec 4 | Song = Struct.new(:title, :album, :composer) 5 | Album = Struct.new(:name, :duration, :songs, :artist) 6 | Artist = Struct.new(:name) 7 | 8 | class ArtistForm < TestForm 9 | property :name 10 | end 11 | 12 | class AlbumForm < TestContract 13 | property :name 14 | 15 | properties :duration 16 | properties :year, :style, readable: false 17 | 18 | validation do 19 | params { required(:name).filled } 20 | end 21 | 22 | collection :songs do 23 | property :title 24 | validation do 25 | params { required(:title).filled } 26 | end 27 | 28 | property :composer do 29 | property :name 30 | validation do 31 | params { required(:name).filled } 32 | end 33 | end 34 | end 35 | 36 | property :artist, form: ArtistForm 37 | end 38 | 39 | let(:song) { Song.new("Broken") } 40 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 41 | let(:composer) { Artist.new("Greg Graffin") } 42 | let(:artist) { Artist.new("Bad Religion") } 43 | let(:album) { Album.new("The Dissent Of Man", 123, [song, song_with_composer], artist) } 44 | 45 | let(:form) { AlbumForm.new(album) } 46 | 47 | # accept `property form: SongForm`. 48 | it do 49 | assert form.artist.is_a? ArtistForm 50 | end 51 | 52 | describe ".properties" do 53 | it "defines a property when called with one argument" do 54 | assert_respond_to form, :duration 55 | end 56 | 57 | it "defines several properties when called with multiple arguments" do 58 | assert_respond_to form, :year 59 | assert_respond_to form, :style 60 | end 61 | 62 | it "passes options to each property when options are provided" do 63 | readable = AlbumForm.new(album).options_for(:style)[:readable] 64 | assert_equal readable, false 65 | end 66 | 67 | it "returns the list of defined properties" do 68 | returned_value = AlbumForm.properties(:hello, :world, virtual: true) 69 | assert_equal returned_value, %i[hello world] 70 | end 71 | end 72 | 73 | describe "#options_for" do 74 | it { assert_equal AlbumForm.options_for(:name).extend(Declarative::Inspect).inspect, "#:name, :name=>\"name\"}>" } 75 | it { assert_equal AlbumForm.new(album).options_for(:name).extend(Declarative::Inspect).inspect, "#:name, :name=>\"name\"}>" } 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/default_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class DefaultTest < Minitest::Spec 4 | Song = Struct.new(:title, :album, :composer) 5 | Album = Struct.new(:name, :songs, :artist) 6 | Artist = Struct.new(:name) 7 | 8 | class AlbumForm < TestForm 9 | property :name, default: "Wrong" 10 | 11 | collection :songs do 12 | property :title, default: "It's Catching Up" 13 | end 14 | end 15 | 16 | it do 17 | form = AlbumForm.new(Album.new(nil, [Song.new])) 18 | 19 | assert_equal form.name, "Wrong" 20 | assert_equal form.songs[0].title, "It's Catching Up" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/deserialize_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "representable/json" 3 | 4 | class DeserializeTest < MiniTest::Spec 5 | Song = Struct.new(:title, :album, :composer) 6 | Album = Struct.new(:title, :artist) 7 | Artist = Struct.new(:name, :callname) 8 | 9 | class JsonAlbumForm < TestForm 10 | module Json 11 | def deserialize(params) 12 | deserializer.new(self). 13 | # extend(Representable::Debug). 14 | from_json(params) 15 | end 16 | 17 | def deserializer 18 | Disposable::Rescheme.from(self.class, 19 | include: [Representable::JSON], 20 | superclass: Representable::Decorator, 21 | definitions_from: ->(inline) { inline.definitions }, 22 | options_from: :deserializer, 23 | exclude_options: [:populator] 24 | ) 25 | end 26 | end 27 | include Json 28 | 29 | property :title 30 | property :artist, populate_if_empty: Artist do 31 | property :name 32 | end 33 | end 34 | 35 | let(:artist) { Artist.new("A-ha") } 36 | it do 37 | artist_id = artist.object_id 38 | 39 | form = JsonAlbumForm.new(Album.new("Best Of", artist)) 40 | json = MultiJson.dump({title: "Apocalypse Soon", artist: {name: "Mute"}}) 41 | 42 | form.validate(json) 43 | 44 | assert_equal form.title, "Apocalypse Soon" 45 | assert_equal form.artist.name, "Mute" 46 | assert_equal form.artist.model.object_id, artist_id 47 | end 48 | 49 | describe "infering the deserializer from another form should NOT copy its populators" do 50 | class CompilationForm < TestForm 51 | property :artist, populator: ->(options) { self.artist = Artist.new(nil, options[:fragment].to_s) } do 52 | property :name 53 | end 54 | 55 | def deserializer 56 | super(JsonAlbumForm, include: [Representable::Hash]) 57 | end 58 | end 59 | 60 | # also tests the Form#deserializer API. # FIXME. 61 | it "uses deserializer inferred from JsonAlbumForm but deserializes/populates to CompilationForm" do 62 | form = CompilationForm.new(Album.new) 63 | form.validate("artist" => {"name" => "Horowitz"}) # the deserializer doesn't know symbols. 64 | form.sync 65 | assert_equal form.artist.model, Artist.new("Horowitz", %{{"name"=>"Horowitz"}}) 66 | end 67 | end 68 | end 69 | 70 | class ValidateWithBlockTest < MiniTest::Spec 71 | Song = Struct.new(:title, :album, :composer) 72 | Album = Struct.new(:title, :artist) 73 | Artist = Struct.new(:name) 74 | 75 | class AlbumForm < TestForm 76 | property :title 77 | property :artist, populate_if_empty: Artist do 78 | property :name 79 | end 80 | end 81 | 82 | it do 83 | album = Album.new 84 | form = AlbumForm.new(album) 85 | json = MultiJson.dump({title: "Apocalypse Soon", artist: {name: "Mute"}}) 86 | 87 | deserializer = Disposable::Rescheme.from(AlbumForm, 88 | include: [Representable::JSON], 89 | superclass: Representable::Decorator, 90 | definitions_from: ->(inline) { inline.definitions }, 91 | options_from: :deserializer 92 | ) 93 | 94 | assert form.validate(json) { |params| 95 | deserializer.new(form).from_json(params) 96 | } # with block must return result, too. 97 | 98 | assert_equal form.title, "Apocalypse Soon" 99 | assert_equal form.artist.name, "Mute" 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/docs/validation_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'reform/form/dry' 3 | 4 | class DocsDryVTest < Minitest::Spec 5 | #:basic 6 | class AlbumForm < Reform::Form 7 | feature Reform::Form::Dry 8 | 9 | property :name 10 | 11 | validation do 12 | params do 13 | required(:name).filled 14 | end 15 | end 16 | end 17 | #:basic end 18 | 19 | it 'validates correctly' do 20 | form = DocsDryVTest::AlbumForm.new(Album.new(nil, nil, nil)) 21 | result = form.call(name: nil) 22 | 23 | refute result.success? 24 | assert_equal({ name: ['must be filled'] }, form.errors.messages) 25 | end 26 | end 27 | 28 | class DocsDryVWithRulesTest < Minitest::Spec 29 | #:basic_with_rules 30 | class AlbumForm < Reform::Form 31 | feature Reform::Form::Dry 32 | 33 | property :name 34 | 35 | validation name: :default do 36 | option :form 37 | 38 | params do 39 | required(:name).filled 40 | end 41 | 42 | rule(:name) do 43 | key.failure('must be unique') if Album.where.not(id: form.model.id).where(name: value).exists? 44 | end 45 | end 46 | end 47 | #:basic_with_rules end 48 | 49 | it 'validates correctly' do 50 | Album = Struct.new(:name, :songs, :artist, :user) 51 | form = DocsDryVWithRulesTest::AlbumForm.new(Album.new(nil, nil, nil, nil)) 52 | result = form.call(name: nil) 53 | 54 | refute result.success? 55 | assert_equal({ name: ['must be filled'] }, form.errors.messages) 56 | end 57 | end 58 | 59 | class DryVWithNestedTest < Minitest::Spec 60 | #:nested 61 | class AlbumForm < Reform::Form 62 | feature Reform::Form::Dry 63 | 64 | property :name 65 | 66 | validation do 67 | params { required(:name).filled } 68 | end 69 | 70 | property :artist do 71 | property :name 72 | 73 | validation do 74 | params { required(:name).filled } 75 | end 76 | end 77 | end 78 | #:nested end 79 | 80 | it 'validates correctly' do 81 | form = DryVWithNestedTest::AlbumForm.new(Album.new(nil, nil, Artist.new(nil))) 82 | result = form.call(name: nil, artist: { name: '' }) 83 | 84 | refute result.success? 85 | assert_equal({ name: ['must be filled'], 'artist.name': ['must be filled'] }, form.errors.messages) 86 | end 87 | end 88 | 89 | class DryVValGroupTest < Minitest::Spec 90 | class AlbumForm < Reform::Form 91 | feature Reform::Form::Dry 92 | 93 | property :name 94 | property :artist 95 | #:validation_groups 96 | validation name: :default do 97 | params { required(:name).filled } 98 | end 99 | 100 | validation name: :artist, if: :default do 101 | params { required(:artist).filled } 102 | end 103 | 104 | validation name: :famous, after: :default do 105 | params { optional(:artist) } 106 | 107 | rule(:artist) do 108 | if value 109 | key.failure('only famous artist') unless value =~ /famous/ 110 | end 111 | end 112 | end 113 | #:validation_groups end 114 | end 115 | 116 | it 'validates correctly' do 117 | form = DryVValGroupTest::AlbumForm.new(Album.new(nil, nil, nil)) 118 | result = form.call(name: nil) 119 | 120 | refute result.success? 121 | assert_equal({ name: ['must be filled'] }, result.errors.messages) 122 | 123 | result = form.call(name: 'Title') 124 | refute result.success? 125 | assert_equal({ artist: ['must be filled'] }, result.errors.messages) 126 | 127 | result = form.call(name: 'Title', artist: 'Artist') 128 | refute result.success? 129 | assert_equal({ artist: ['only famous artist'] }, result.errors.messages) 130 | 131 | result = form.call(name: 'Title', artist: 'Artist famous') 132 | assert result.success? 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/errors_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ErrorsTest < MiniTest::Spec 4 | class AlbumForm < TestForm 5 | property :title 6 | validation do 7 | params { required(:title).filled } 8 | end 9 | 10 | property :artists, default: [] 11 | property :producer do 12 | property :name 13 | end 14 | 15 | property :hit do 16 | property :title 17 | validation do 18 | params { required(:title).filled } 19 | end 20 | end 21 | 22 | collection :songs do 23 | property :title 24 | validation do 25 | params { required(:title).filled } 26 | end 27 | end 28 | 29 | property :band do # yepp, people do crazy stuff like that. 30 | property :name 31 | property :label do 32 | property :name 33 | validation do 34 | params { required(:name).filled } 35 | end 36 | end 37 | # TODO: make band a required object. 38 | 39 | validation do 40 | config.messages.load_paths << "test/fixtures/dry_error_messages.yml" 41 | 42 | params { required(:name).filled } 43 | 44 | rule(:name) { key.failure(:good_musical_taste?) if value == "Nickelback" } 45 | end 46 | end 47 | 48 | validation do 49 | params do 50 | required(:title).filled 51 | required(:artists).each(:str?) 52 | required(:producer).hash do 53 | required(:name).filled 54 | end 55 | end 56 | end 57 | end 58 | 59 | let(:album_title) { "Blackhawks Over Los Angeles" } 60 | let(:album) do 61 | OpenStruct.new( 62 | title: album_title, 63 | hit: song, 64 | songs: songs, # TODO: document this requirement, 65 | band: Struct.new(:name, :label).new("Epitaph", OpenStruct.new), 66 | producer: Struct.new(:name).new("Sun Records") 67 | ) 68 | end 69 | let(:song) { OpenStruct.new(title: "Downtown") } 70 | let(:songs) { [song = OpenStruct.new(title: "Calling"), song] } 71 | let(:form) { AlbumForm.new(album) } 72 | 73 | describe "#validate with invalid array property" do 74 | it do 75 | refute form.validate( 76 | title: "Swimming Pool - EP", 77 | band: { 78 | name: "Marie Madeleine", 79 | label: {name: "Ekler'o'shocK"} 80 | }, 81 | artists: [42, "Good Charlotte", 43] 82 | ) 83 | assert_equal form.errors.messages, artists: {0 => ["must be a string"], 2 => ["must be a string"]} 84 | assert_equal form.errors.size, 1 85 | end 86 | end 87 | 88 | describe "#errors without #validate" do 89 | it do 90 | assert_equal form.errors.size, 0 91 | end 92 | end 93 | 94 | describe "blank everywhere" do 95 | before do 96 | form.validate( 97 | "hit" => {"title" => ""}, 98 | "title" => "", 99 | "songs" => [{"title" => ""}, {"title" => ""}], 100 | "producer" => {"name" => ""} 101 | ) 102 | end 103 | 104 | it do 105 | assert_equal form.errors.messages,{ 106 | title: ["must be filled"], 107 | "hit.title": ["must be filled"], 108 | "songs.title": ["must be filled"], 109 | "band.label.name": ["must be filled"], 110 | "producer.name": ["must be filled"] 111 | } 112 | end 113 | 114 | # it do 115 | # form.errors.must_equal({:title => ["must be filled"]}) 116 | # TODO: this should only contain local errors? 117 | # end 118 | 119 | # nested forms keep their own Errors: 120 | it { assert_equal form.producer.errors.messages, name: ["must be filled"] } 121 | it { assert_equal form.hit.errors.messages, title: ["must be filled"] } 122 | it { assert_equal form.songs[0].errors.messages, title: ["must be filled"] } 123 | 124 | it do 125 | assert_equal form.errors.messages, { 126 | title: ["must be filled"], 127 | "hit.title": ["must be filled"], 128 | "songs.title": ["must be filled"], 129 | "band.label.name": ["must be filled"], 130 | "producer.name": ["must be filled"] 131 | } 132 | assert_equal form.errors.size, 5 133 | end 134 | end 135 | 136 | describe "#validate with main form invalid" do 137 | it do 138 | refute form.validate("title" => "", "band" => {"label" => {name: "Fat Wreck"}}, "producer" => nil) 139 | assert_equal form.errors.messages, title: ["must be filled"], producer: ["must be a hash"] 140 | assert_equal form.errors.size, 2 141 | end 142 | end 143 | 144 | describe "#validate with middle nested form invalid" do 145 | before { @result = form.validate("hit" => {"title" => ""}, "band" => {"label" => {name: "Fat Wreck"}}) } 146 | 147 | it { refute @result } 148 | it { assert_equal form.errors.messages, "hit.title": ["must be filled"] } 149 | it { assert_equal form.errors.size, 1 } 150 | end 151 | 152 | describe "#validate with collection form invalid" do 153 | before { @result = form.validate("songs" => [{"title" => ""}], "band" => {"label" => {name: "Fat Wreck"}}) } 154 | 155 | it { refute @result } 156 | it { assert_equal form.errors.messages, "songs.title": ["must be filled"] } 157 | it { assert_equal form.errors.size, 1 } 158 | end 159 | 160 | describe "#validate with collection and 2-level-nested invalid" do 161 | before { @result = form.validate("songs" => [{"title" => ""}], "band" => {"label" => {}}) } 162 | 163 | it { refute @result } 164 | it { assert_equal form.errors.messages, "songs.title": ["must be filled"], "band.label.name": ["must be filled"] } 165 | it { assert_equal form.errors.size, 2 } 166 | end 167 | 168 | describe "#validate with nested form using :base invalid" do 169 | it do 170 | result = form.validate("songs" => [{"title" => "Someday"}], "band" => {"name" => "Nickelback", "label" => {"name" => "Roadrunner Records"}}) 171 | refute result 172 | assert_equal form.errors.messages, "band.name": ["you're a bad person"] 173 | assert_equal form.errors.size, 1 174 | end 175 | end 176 | 177 | describe "#add" do 178 | let(:album_title) { nil } 179 | it do 180 | result = form.validate("songs" => [{"title" => "Someday"}], "band" => {"name" => "Nickelback", "label" => {"name" => "Roadrunner Records"}}) 181 | refute result 182 | assert_equal form.errors.messages, title: ["must be filled"], "band.name": ["you're a bad person"] 183 | # add a new custom error 184 | form.errors.add(:policy, "error_text") 185 | assert_equal form.errors.messages, title: ["must be filled"], "band.name": ["you're a bad person"], policy: ["error_text"] 186 | # does not duplicate errors 187 | form.errors.add(:title, "must be filled") 188 | assert_equal form.errors.messages, title: ["must be filled"], "band.name": ["you're a bad person"], policy: ["error_text"] 189 | # merge existing errors 190 | form.errors.add(:policy, "another error") 191 | assert_equal form.errors.messages, title: ["must be filled"], "band.name": ["you're a bad person"], policy: ["error_text", "another error"] 192 | end 193 | end 194 | 195 | describe "correct #validate" do 196 | before do 197 | @result = form.validate( 198 | "hit" => {"title" => "Sacrifice"}, 199 | "title" => "Second Heat", 200 | "songs" => [{"title" => "Heart Of A Lion"}], 201 | "band" => {"label" => {name: "Fat Wreck"}} 202 | ) 203 | end 204 | 205 | it { assert @result } 206 | it { assert_equal form.hit.title, "Sacrifice" } 207 | it { assert_equal form.title, "Second Heat" } 208 | it { assert_equal form.songs.first.title, "Heart Of A Lion" } 209 | it do 210 | skip "WE DON'T NEED COUNT AND EMPTY? ON THE CORE ERRORS OBJECT" 211 | assert_equal form.errors.size, 0 212 | assert form.errors.empty 213 | end 214 | end 215 | 216 | describe "Errors#to_s" do 217 | before { form.validate("songs" => [{"title" => ""}], "band" => {"label" => {}}) } 218 | 219 | # to_s is aliased to messages 220 | it { 221 | skip "why do we need Errors#to_s ?" 222 | assert_equal form.errors.to_s, "{:\"songs.title\"=>[\"must be filled\"], :\"band.label.name\"=>[\"must be filled\"]}" 223 | } 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /test/feature_test.rb: -------------------------------------------------------------------------------- 1 | class FeatureInheritanceTest < BaseTest 2 | Song = Struct.new(:title, :album, :composer) 3 | Album = Struct.new(:name, :songs, :artist) 4 | Artist = Struct.new(:name) 5 | 6 | module Date 7 | def date 8 | "May 16" 9 | end 10 | 11 | def self.included(includer) 12 | includer.send :register_feature, self 13 | end 14 | end 15 | 16 | # module Name 17 | # def name 18 | # "Violins" 19 | # end 20 | # end 21 | 22 | class AlbumForm < TestForm 23 | feature Date # feature. 24 | property :name 25 | 26 | collection :songs do 27 | property :title 28 | 29 | property :composer do 30 | property :name 31 | end 32 | end 33 | 34 | property :artist do 35 | property :name 36 | end 37 | end 38 | 39 | let(:song) { Song.new("Broken") } 40 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 41 | let(:composer) { Artist.new("Greg Graffin") } 42 | let(:artist) { Artist.new("Bad Religion") } 43 | let(:album) { Album.new("The Dissent Of Man", [song, song_with_composer], artist) } 44 | 45 | let(:form) { AlbumForm.new(album) } 46 | 47 | it do 48 | assert_equal form.date, "May 16" 49 | assert_equal form.songs[0].date, "May 16" 50 | end 51 | 52 | # it { subject.class.include?(Reform::Form::ActiveModel) } 53 | # it { subject.class.include?(Reform::Form::Coercion) } 54 | # it { subject.is_a?(Reform::Form::MultiParameterAttributes) } 55 | 56 | # it { subject.band.class.include?(Reform::Form::ActiveModel) } 57 | # it { subject.band.is_a?(Reform::Form::Coercion) } 58 | # it { subject.band.is_a?(Reform::Form::MultiParameterAttributes) } 59 | 60 | # it { subject.band.label.is_a?(Reform::Form::ActiveModel) } 61 | # it { subject.band.label.is_a?(Reform::Form::Coercion) } 62 | # it { subject.band.label.is_a?(Reform::Form::MultiParameterAttributes) } 63 | end 64 | -------------------------------------------------------------------------------- /test/fixtures/dry_error_messages.yml: -------------------------------------------------------------------------------- 1 | en: 2 | dry_validation: 3 | errors: 4 | array?: "must be an array" 5 | 6 | empty?: "must be empty" 7 | 8 | excludes?: "must not include %{value}" 9 | 10 | excluded_from?: 11 | arg: 12 | default: "must not be one of: %{list}" 13 | range: "must not be one of: %{list_left} - %{list_right}" 14 | 15 | eql?: "must be equal to %{left}" 16 | 17 | not_eql?: "must not be equal to %{left}" 18 | 19 | filled?: "must be filled" 20 | 21 | format?: "is in invalid format" 22 | 23 | number?: "must be a number" 24 | 25 | odd?: "must be odd" 26 | 27 | even?: "must be even" 28 | 29 | gt?: "must be greater than %{num}" 30 | 31 | gteq?: "must be greater than or equal to %{num}" 32 | 33 | hash?: "must be a hash" 34 | 35 | included_in?: 36 | arg: 37 | default: "must be one of: %{list}" 38 | range: "must be one of: %{list_left} - %{list_right}" 39 | 40 | includes?: "must include %{value}" 41 | 42 | bool?: "must be boolean" 43 | 44 | true?: "must be true" 45 | 46 | false?: "must be false" 47 | 48 | int?: "must be an integer" 49 | 50 | float?: "must be a float" 51 | 52 | decimal?: "must be a decimal" 53 | 54 | date?: "must be a date" 55 | 56 | date_time?: "must be a date time" 57 | 58 | time?: "must be a time" 59 | 60 | key?: "is missing" 61 | 62 | attr?: "is missing" 63 | 64 | lt?: "must be less than %{num}" 65 | 66 | lteq?: "must be less than or equal to %{num}" 67 | 68 | max_size?: "size cannot be greater than %{num}" 69 | 70 | min_size?: "size cannot be less than %{num}" 71 | 72 | none?: "cannot be defined" 73 | 74 | str?: "must be a string" 75 | 76 | type?: "must be %{type}" 77 | 78 | size?: 79 | arg: 80 | default: "size must be %{size}" 81 | range: "size must be within %{size_left} - %{size_right}" 82 | 83 | value: 84 | string: 85 | arg: 86 | default: "length must be %{size}" 87 | range: "length must be within %{size_left} - %{size_right}" 88 | 89 | 90 | 91 | rules: 92 | name: 93 | good_musical_taste?: "you're a bad person" 94 | title: 95 | good_musical_taste?: "you're a bad person" 96 | songs: 97 | a_song?: "must have at least one enabled song" 98 | artist: 99 | with_last_name?: "must have last name" 100 | 101 | de: 102 | dry_validation: 103 | errors: 104 | filled?: "muss abgefüllt sein" 105 | -------------------------------------------------------------------------------- /test/form_option_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FormOptionTest < MiniTest::Spec 4 | Song = Struct.new(:title) 5 | Album = Struct.new(:song) 6 | 7 | class SongForm < TestForm 8 | property :title 9 | validation do 10 | params { required(:title).filled } 11 | end 12 | end 13 | 14 | class AlbumForm < TestForm 15 | property :song, form: SongForm 16 | end 17 | 18 | it do 19 | form = AlbumForm.new(Album.new(Song.new("When It Comes To You"))) 20 | assert_equal "When It Comes To You", form.song.title 21 | 22 | form.validate(song: {title: "Run For Cover"}) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/form_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FormTest < MiniTest::Spec 4 | Artist = Struct.new(:name) 5 | 6 | class AlbumForm < TestForm 7 | property :title 8 | 9 | property :hit do 10 | property :title 11 | end 12 | 13 | collection :songs do 14 | property :title 15 | end 16 | 17 | property :band do # yepp, people do crazy stuff like that. 18 | property :label do 19 | property :name 20 | end 21 | end 22 | end 23 | 24 | describe "::dup" do 25 | let(:cloned) { AlbumForm.clone } 26 | 27 | # #dup is called in Op.inheritable_attr(:contract_class), it must be subclass of the original one. 28 | it { refute_equal cloned, AlbumForm } 29 | it { refute_equal AlbumForm.definitions, cloned.definitions } 30 | 31 | it do 32 | # currently, forms need a name for validation, even without AM. 33 | cloned.singleton_class.class_eval do 34 | def name 35 | "Album" 36 | end 37 | end 38 | 39 | cloned.validation do 40 | params { required(:title).filled } 41 | end 42 | 43 | cloned.new(OpenStruct.new).validate({}) 44 | end 45 | end 46 | 47 | describe "#initialize" do 48 | class ArtistForm < TestForm 49 | property :name 50 | property :current_user, virtual: true 51 | end 52 | 53 | it "allows injecting :virtual options" do 54 | assert_equal ArtistForm.new(Artist.new, current_user: Object).current_user, Object 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/from_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class AsTest < BaseTest 4 | class AlbumForm < TestForm 5 | property :name, from: :title 6 | 7 | property :single, from: :hit do 8 | property :title 9 | end 10 | 11 | collection :tracks, from: :songs do 12 | property :name, from: :title 13 | end 14 | 15 | property :band do 16 | property :company, from: :label do 17 | property :business, from: :name 18 | end 19 | end 20 | end 21 | 22 | let(:song2) { Song.new("Roxanne") } 23 | 24 | let(:params) do 25 | { 26 | "name" => "Best Of The Police", 27 | "single" => {"title" => "So Lonely"}, 28 | "tracks" => [{"name" => "Message In A Bottle"}, {"name" => "Roxanne"}] 29 | } 30 | end 31 | 32 | subject { AlbumForm.new(Album.new("Best Of", hit, [Song.new("Fallout"), song2])) } 33 | 34 | it { assert_equal subject.name, "Best Of" } 35 | it { assert_equal subject.single.title, "Roxanne" } 36 | it { assert_equal subject.tracks[0].name, "Fallout" } 37 | it { assert_equal subject.tracks[1].name, "Roxanne" } 38 | 39 | describe "#validate" do 40 | 41 | before { subject.validate(params) } 42 | 43 | it { assert_equal subject.name, "Best Of The Police" } 44 | it { assert_equal subject.single.title, "So Lonely" } 45 | it { assert_equal subject.tracks[0].name, "Message In A Bottle" } 46 | it { assert_equal subject.tracks[1].name, "Roxanne" } 47 | end 48 | 49 | describe "#sync" do 50 | before do 51 | subject.tracks[1].name = "Livin' Ain't No Crime" 52 | subject.sync 53 | end 54 | 55 | it { assert_equal song2.title, "Livin' Ain't No Crime" } 56 | end 57 | 58 | describe "#save (nested hash)" do 59 | before { subject.validate(params) } 60 | 61 | it do 62 | hash = nil 63 | 64 | subject.save do |nested_hash| 65 | hash = nested_hash 66 | end 67 | 68 | assert_equal hash, "title" => "Best Of The Police", "hit" => {"title" => "So Lonely"}, "songs" => [{"title" => "Message In A Bottle"}, {"title" => "Roxanne"}], "band" => nil 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/inherit_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "representable/json" 3 | 4 | class InheritTest < BaseTest 5 | Populator = Reform::Form::Populator 6 | 7 | class SkipParse 8 | include Uber::Callable 9 | def call(*_args) 10 | false 11 | end 12 | end 13 | 14 | class AlbumForm < TestForm 15 | property :title, deserializer: {instance: "Instance"}, skip_if: "skip_if in AlbumForm" # allow direct configuration of :deserializer. 16 | 17 | property :hit, populate_if_empty: ->(*) { Song.new } do 18 | property :title 19 | validation do 20 | params { required(:title).filled } 21 | end 22 | end 23 | 24 | collection :songs, populate_if_empty: -> {}, skip_if: :all_blank do 25 | property :title 26 | end 27 | 28 | property :band, populate_if_empty: -> {} do 29 | def band_id 30 | 1 31 | end 32 | end 33 | end 34 | 35 | class CompilationForm < AlbumForm 36 | property :title, inherit: true, skip_if: "skip_if from CompilationForm" 37 | property :hit, inherit: true, populate_if_empty: ->(*) { Song.new }, skip_if: SkipParse.new do 38 | property :rating 39 | validation do 40 | params { required(:rating).filled } 41 | end 42 | end 43 | 44 | # NO collection here, this is entirely inherited. 45 | 46 | property :band, inherit: true do # inherit everything, but explicitely. 47 | end 48 | end 49 | 50 | let(:album) { Album.new(nil, Song.new, [], Band.new) } 51 | subject { CompilationForm.new(album) } 52 | 53 | it do 54 | subject.validate("hit" => {"title" => "LA Drone", "rating" => 10}) 55 | assert_equal subject.hit.title, "LA Drone" 56 | assert_equal subject.hit.rating, 10 57 | assert_equal subject.errors.messages, {} 58 | end 59 | 60 | it do 61 | subject.validate({}) 62 | assert_nil subject.model.hit.title 63 | assert_nil subject.model.hit.rating 64 | assert_equal subject.errors.messages, "hit.title": ["must be filled"], "hit.rating": ["must be filled"] 65 | end 66 | 67 | it "xxx" do 68 | # sub hashes like :deserializer must be properly cloned when inheriting. 69 | refute_equal AlbumForm.options_for(:title)[:deserializer].object_id, CompilationForm.options_for(:title)[:deserializer].object_id 70 | 71 | # don't overwrite direct deserializer: {} configuration. 72 | assert AlbumForm.options_for(:title)[:internal_populator].is_a? Reform::Form::Populator::Sync 73 | assert_equal AlbumForm.options_for(:title)[:deserializer][:skip_parse], "skip_if in AlbumForm" 74 | 75 | # AlbumForm.options_for(:hit)[:internal_populator].inspect.must_match /Reform::Form::Populator:.+ @user_proc="Populator"/ 76 | # AlbumForm.options_for(:hit)[:deserializer][:instance].inspect.must_be_instance_with Reform::Form::Populator, user_proc: "Populator" 77 | 78 | assert AlbumForm.options_for(:songs)[:internal_populator].is_a? Reform::Form::Populator::IfEmpty 79 | assert AlbumForm.options_for(:songs)[:deserializer][:skip_parse].is_a? Reform::Form::Validate::Skip::AllBlank 80 | 81 | assert AlbumForm.options_for(:band)[:internal_populator].is_a? Reform::Form::Populator::IfEmpty 82 | 83 | assert_equal CompilationForm.options_for(:title)[:deserializer][:skip_parse], "skip_if from CompilationForm" 84 | # pp CompilationForm.options_for(:songs) 85 | assert CompilationForm.options_for(:songs)[:internal_populator].is_a? Reform::Form::Populator::IfEmpty 86 | 87 | assert CompilationForm.options_for(:band)[:internal_populator].is_a? Reform::Form::Populator::IfEmpty 88 | 89 | # completely overwrite inherited. 90 | assert CompilationForm.options_for(:hit)[:deserializer][:skip_parse].is_a? SkipParse 91 | 92 | # inherit: true with block will still inherit the original class. 93 | assert_equal AlbumForm.new(OpenStruct.new(band: OpenStruct.new)).band.band_id, 1 94 | assert_equal CompilationForm.new(OpenStruct.new(band: OpenStruct.new)).band.band_id, 1 95 | end 96 | 97 | class CDForm < AlbumForm 98 | # override :band's original populate_if_empty but with :inherit. 99 | property :band, inherit: true, populator: "CD Populator" do 100 | 101 | end 102 | end 103 | 104 | it { assert_equal CDForm.options_for(:band)[:internal_populator].instance_variable_get(:@user_proc), "CD Populator" } 105 | end 106 | -------------------------------------------------------------------------------- /test/module_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "reform/form/coercion" 3 | 4 | class ModuleInclusionTest < MiniTest::Spec 5 | module BandPropertyForm 6 | include Reform::Form::Module 7 | 8 | property :band do 9 | property :title 10 | 11 | validation do 12 | params { required(:title).filled } 13 | end 14 | 15 | def id # gets mixed into Form, too. 16 | 2 17 | end 18 | end 19 | 20 | def id # gets mixed into Form, too. 21 | 1 22 | end 23 | 24 | validation do 25 | params { required(:band).filled } 26 | end 27 | 28 | include Dry.Types(default: :nominal) # allows using Types::* in module. 29 | property :cool, type: Types::Params::Bool # test coercion. 30 | end 31 | 32 | # TODO: test if works, move stuff into inherit_schema! 33 | module AirplaysPropertyForm 34 | include Reform::Form::Module 35 | 36 | collection :airplays do 37 | property :station 38 | validation do 39 | params { required(:station).filled } 40 | end 41 | end 42 | validation do 43 | params { required(:airplays).filled } 44 | end 45 | end 46 | 47 | # test: 48 | # by including BandPropertyForm into multiple classes we assure that options hashes don't get messed up by AM:V. 49 | class HitForm < TestForm 50 | include BandPropertyForm 51 | end 52 | 53 | class SongForm < TestForm 54 | include Coercion 55 | property :title 56 | 57 | include BandPropertyForm 58 | end 59 | 60 | let(:song) { OpenStruct.new(band: OpenStruct.new(title: "Time Again")) } 61 | 62 | # nested form from module is present and creates accessor. 63 | it { assert_equal SongForm.new(song).band.title, "Time Again" } 64 | 65 | # methods from module get included. 66 | it { assert_equal SongForm.new(song).id, 1 } 67 | it { assert_equal SongForm.new(song).band.id, 2 } 68 | 69 | # validators get inherited. 70 | it do 71 | form = SongForm.new(OpenStruct.new) 72 | form.validate({}) 73 | assert_equal form.errors.messages, band: ["must be filled"] 74 | end 75 | 76 | # coercion works 77 | it do 78 | form = SongForm.new(OpenStruct.new) 79 | form.validate(cool: "1") 80 | assert form.cool 81 | end 82 | 83 | # include a module into a module into a class :) 84 | module AlbumFormModule 85 | include Reform::Form::Module 86 | include BandPropertyForm 87 | 88 | property :name 89 | validation do 90 | params { required(:name).filled } 91 | end 92 | end 93 | 94 | class AlbumForm < TestForm 95 | include AlbumFormModule 96 | 97 | # pp heritage 98 | property :band, inherit: true do 99 | property :label 100 | validation do 101 | params { required(:label).filled } 102 | end 103 | end 104 | end 105 | 106 | it do 107 | form = AlbumForm.new(OpenStruct.new(band: OpenStruct.new)) 108 | form.validate("band" => {}) 109 | assert_equal form.errors.messages, "band.title": ["must be filled"], "band.label": ["must be filled"], name: ["must be filled"] 110 | end 111 | 112 | describe "module with custom accessors" do 113 | module SongModule 114 | include Reform::Form::Module 115 | 116 | property :id # no custom accessor for id. 117 | property :title # has custom accessor. 118 | 119 | module InstanceMethods 120 | def title 121 | super.upcase 122 | end 123 | end 124 | end 125 | 126 | class IncludingSongForm < TestForm 127 | include SongModule 128 | end 129 | 130 | let(:song) { OpenStruct.new(id: 1, title: "Instant Mash") } 131 | 132 | it do 133 | assert_equal IncludingSongForm.new(song).id, 1 134 | assert_equal IncludingSongForm.new(song).title, "INSTANT MASH" 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/parse_option_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ParseOptionTest < MiniTest::Spec 4 | Comment = Struct.new(:content, :user) 5 | User = Struct.new(:name) 6 | 7 | class CommentForm < TestForm 8 | property :content 9 | property :user, parse: false 10 | end 11 | 12 | let(:current_user) { User.new("Peter") } 13 | let(:form) { CommentForm.new(Comment.new, user: current_user) } 14 | 15 | it do 16 | assert_equal form.user, current_user 17 | 18 | lorem = "Lorem ipsum dolor sit amet..." 19 | form.validate("content" => lorem, "user" => "not the current user") 20 | 21 | assert_equal form.content, lorem 22 | assert_equal form.user, current_user 23 | end 24 | 25 | describe "using ':parse' option doesn't override other ':deserialize' options" do 26 | class ArticleCommentForm < TestForm 27 | property :content 28 | property :article, deserializer: {instance: "Instance"} 29 | property :user, parse: false, deserializer: {instance: "Instance"} 30 | end 31 | 32 | it do 33 | assert_equal ArticleCommentForm.definitions.get(:user)[:deserializer][:writeable], false 34 | assert_equal ArticleCommentForm.definitions.get(:user)[:deserializer][:instance], "Instance" 35 | 36 | assert ArticleCommentForm.definitions.get(:article)[:deserializer][:writeable] 37 | assert_equal ArticleCommentForm.definitions.get(:article)[:deserializer][:instance], "Instance" 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/parse_pipeline_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ParsePipelineTest < MiniTest::Spec 4 | Album = Struct.new(:name) 5 | 6 | class AlbumForm < TestForm 7 | property :name, deserializer: {parse_pipeline: ->(input, options) { Representable::Pipeline[->(ipt, opts) { opts[:represented].name = ipt.inspect }] }} 8 | end 9 | 10 | it "allows passing :parse_pipeline directly" do 11 | form = AlbumForm.new(Album.new) 12 | form.validate("name" => "Greatest Hits") 13 | assert_equal form.name, "{\"name\"=>\"Greatest Hits\"}" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/populate_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PopulatorTest < MiniTest::Spec 4 | Song = Struct.new(:title, :album, :composer) 5 | Album = Struct.new(:name, :songs, :artist) 6 | Artist = Struct.new(:name) 7 | 8 | class AlbumForm < TestForm 9 | property :name, populator: ->(options) { self.name = options[:fragment].reverse } 10 | validation do 11 | params { required(:name).filled } 12 | end 13 | 14 | collection :songs, 15 | populator: ->(fragment:, model:, index:, **) { 16 | (item = model[index]) ? item : model.insert(index, Song.new) 17 | } do 18 | property :title 19 | validation do 20 | params { required(:title).filled } 21 | end 22 | 23 | property :composer, populator: ->(options) { options[:model] || self.composer = Artist.new } do 24 | property :name 25 | validation do 26 | params { required(:name).filled } 27 | end 28 | end 29 | end 30 | 31 | # property :artist, populator: lambda { |fragment, options| (item = options.binding.get) ? item : Artist.new } do 32 | # NOTE: we have to document that model here is the twin! 33 | property :artist, populator: ->(options) { options[:model] || self.artist = Artist.new } do 34 | property :name 35 | end 36 | end 37 | 38 | let(:song) { Song.new("Broken") } 39 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 40 | let(:composer) { Artist.new("Greg Graffin") } 41 | let(:artist) { Artist.new("Bad Religion") } 42 | let(:album) { Album.new("The Dissent Of Man", [song, song_with_composer], artist) } 43 | 44 | let(:form) { AlbumForm.new(album) } 45 | 46 | it "runs populator on scalar" do 47 | form.validate( 48 | "name" => "override me!" 49 | ) 50 | 51 | assert_equal form.name, "!em edirrevo" 52 | end 53 | 54 | # changing existing property :artist. 55 | # TODO: check with artist==nil 56 | it do 57 | old_id = artist.object_id 58 | 59 | form.validate( 60 | "artist" => {"name" => "Marcus Miller"} 61 | ) 62 | 63 | assert_equal form.artist.model.object_id, old_id 64 | end 65 | 66 | # use populator for default value on scalars? 67 | 68 | # adding to collection via :populator. 69 | # valid. 70 | it "yyy" do 71 | assert form.validate( 72 | "songs" => [{"title" => "Fallout"}, {"title" => "Roxanne"}, 73 | {"title" => "Rime Of The Ancient Mariner"}, # new song. 74 | {"title" => "Re-Education", "composer" => {"name" => "Rise Against"}}], # new song with new composer. 75 | ) 76 | 77 | assert_equal form.errors.messages.inspect, "{}" 78 | 79 | # form has updated. 80 | assert_equal form.name, "The Dissent Of Man" 81 | assert_equal form.songs[0].title, "Fallout" 82 | assert_equal form.songs[1].title, "Roxanne" 83 | assert_equal form.songs[1].composer.name, "Greg Graffin" 84 | 85 | form.songs[1].composer.model.is_a? Artist 86 | 87 | assert_equal form.songs[1].title, "Roxanne" 88 | assert_equal form.songs[2].title, "Rime Of The Ancient Mariner" # new song added. 89 | assert_equal form.songs[3].title, "Re-Education" 90 | assert_equal form.songs[3].composer.name, "Rise Against" 91 | assert_equal form.songs.size, 4 92 | assert_equal form.artist.name, "Bad Religion" 93 | 94 | # model has not changed, yet. 95 | assert_equal album.name, "The Dissent Of Man" 96 | assert_equal album.songs[0].title, "Broken" 97 | assert_equal album.songs[1].title, "Resist Stance" 98 | assert_equal album.songs[1].composer.name, "Greg Graffin" 99 | assert_equal album.songs.size, 2 100 | assert_equal album.artist.name, "Bad Religion" 101 | end 102 | end 103 | 104 | class PopulateWithMethodTest < Minitest::Spec 105 | Album = Struct.new(:title) 106 | 107 | class AlbumForm < TestForm 108 | property :title, populator: :title! 109 | 110 | def title!(options) 111 | self.title = options[:fragment].reverse 112 | end 113 | end 114 | 115 | let(:form) { AlbumForm.new(Album.new) } 116 | 117 | it "runs populator method" do 118 | form.validate("title" => "override me!") 119 | 120 | assert_equal form.title, "!em edirrevo" 121 | end 122 | end 123 | 124 | class PopulateWithCallableTest < Minitest::Spec 125 | Album = Struct.new(:title) 126 | 127 | class TitlePopulator 128 | include Uber::Callable 129 | 130 | def call(form:, **options) 131 | form.title = options[:fragment].reverse 132 | end 133 | end 134 | 135 | class TitlePopulatorWithOldSignature 136 | include Uber::Callable 137 | 138 | def call(form, options) 139 | form.title = options[:fragment].reverse 140 | end 141 | end 142 | 143 | class AlbumForm < TestForm 144 | property :title, populator: TitlePopulator.new 145 | end 146 | 147 | class AlbumFormWithOldPopulator < TestForm 148 | property :title, populator: TitlePopulatorWithOldSignature.new 149 | end 150 | 151 | let(:form) { AlbumForm.new(Album.new) } 152 | 153 | it "runs populator method" do 154 | form.validate("title" => "override me!") 155 | 156 | assert_equal form.title, "!em edirrevo" 157 | end 158 | 159 | it "gives warning when `form` is accepted as a positional argument" do 160 | _, warnings = capture_io do 161 | form = AlbumFormWithOldPopulator.new(Album.new) 162 | form.validate("title" => "override me!") 163 | 164 | assert_equal form.title, "!em edirrevo" 165 | end 166 | 167 | assert_equal warnings, %{[Reform] Accepting `form` as a positional argument in `:populator` will be deprecated. Please use `def call(form:, **options)` signature instead. 168 | } 169 | end 170 | end 171 | 172 | class PopulateWithProcTest < Minitest::Spec 173 | Album = Struct.new(:title) 174 | 175 | TitlePopulator = ->(options) do 176 | options[:represented].title = options[:fragment].reverse 177 | end 178 | 179 | class AlbumForm < TestForm 180 | property :title, populator: TitlePopulator 181 | end 182 | 183 | let(:form) { AlbumForm.new(Album.new) } 184 | 185 | it "runs populator method" do 186 | form.validate("title" => "override me!") 187 | 188 | assert_equal form.title, "!em edirrevo" 189 | end 190 | end 191 | 192 | class PopulateIfEmptyTest < MiniTest::Spec 193 | Song = Struct.new(:title, :album, :composer) 194 | Album = Struct.new(:name, :songs, :artist) 195 | Artist = Struct.new(:name) 196 | 197 | let(:song) { Song.new("Broken") } 198 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 199 | let(:composer) { Artist.new("Greg Graffin") } 200 | let(:artist) { Artist.new("Bad Religion") } 201 | let(:album) { Album.new("The Dissent Of Man", [song, song_with_composer], artist) } 202 | 203 | class AlbumForm < TestForm 204 | property :name 205 | 206 | collection :songs, 207 | populate_if_empty: Song do # class name works. 208 | 209 | property :title 210 | validation do 211 | params { required(:title).filled } 212 | end 213 | 214 | property :composer, populate_if_empty: :populate_composer! do # lambda works, too. in form context. 215 | property :name 216 | validation do 217 | params { required(:name).filled } 218 | end 219 | end 220 | 221 | private 222 | def populate_composer!(options) 223 | Artist.new 224 | end 225 | end 226 | 227 | property :artist, populate_if_empty: ->(args) { create_artist(args[:fragment], args[:user_options]) } do # methods work, too. 228 | property :name 229 | end 230 | 231 | private 232 | class Sting < Artist 233 | attr_accessor :args 234 | end 235 | def create_artist(input, user_options) 236 | Sting.new.tap { |artist| artist.args = ([input, user_options].to_s) } 237 | end 238 | end 239 | 240 | let(:form) { AlbumForm.new(album) } 241 | 242 | it do 243 | assert_equal form.songs.size, 2 244 | 245 | assert form.validate( 246 | "songs" => [{"title" => "Fallout"}, {"title" => "Roxanne"}, 247 | {"title" => "Rime Of The Ancient Mariner"}, # new song. 248 | {"title" => "Re-Education", "composer" => {"name" => "Rise Against"}}], # new song with new composer. 249 | ) 250 | 251 | assert_equal form.errors.messages.inspect, "{}" 252 | 253 | # form has updated. 254 | assert_equal form.name, "The Dissent Of Man" 255 | assert_equal form.songs[0].title, "Fallout" 256 | assert_equal form.songs[1].title, "Roxanne" 257 | assert_equal form.songs[1].composer.name, "Greg Graffin" 258 | assert_equal form.songs[1].title, "Roxanne" 259 | assert_equal form.songs[2].title, "Rime Of The Ancient Mariner" # new song added. 260 | assert_equal form.songs[3].title, "Re-Education" 261 | assert_equal form.songs[3].composer.name, "Rise Against" 262 | assert_equal form.songs.size, 4 263 | assert_equal form.artist.name, "Bad Religion" 264 | 265 | # model has not changed, yet. 266 | assert_equal album.name, "The Dissent Of Man" 267 | assert_equal album.songs[0].title, "Broken" 268 | assert_equal album.songs[1].title, "Resist Stance" 269 | assert_equal album.songs[1].composer.name, "Greg Graffin" 270 | assert_equal album.songs.size, 2 271 | assert_equal album.artist.name, "Bad Religion" 272 | end 273 | 274 | # trigger artist populator. lambda calling form instance method. 275 | it "xxxx" do 276 | form = AlbumForm.new(album = Album.new) 277 | form.validate("artist" => {"name" => "From Autumn To Ashes"}) 278 | 279 | assert_equal form.artist.name, "From Autumn To Ashes" 280 | # test lambda was executed in form context. 281 | assert form.artist.model.is_a? AlbumForm::Sting 282 | # test lambda block arguments. 283 | assert_equal form.artist.model.args.to_s, "[{\"name\"=>\"From Autumn To Ashes\"}, nil]" 284 | 285 | assert_nil album.artist 286 | end 287 | end 288 | 289 | # delete songs while deserializing. 290 | class PopulateIfEmptyWithDeletionTest < MiniTest::Spec 291 | Song = Struct.new(:title, :album, :composer) 292 | Album = Struct.new(:name, :songs, :artist) 293 | 294 | let(:song) { Song.new("Broken") } 295 | let(:song2) { Song.new("Resist Stance") } 296 | let(:album) { Album.new("The Dissent Of Man", [song, song2]) } 297 | 298 | class AlbumForm < TestForm 299 | property :name 300 | 301 | collection :songs, 302 | populate_if_empty: Song, skip_if: :delete_song! do 303 | 304 | property :title 305 | validation do 306 | params { required(:title).filled } 307 | end 308 | end 309 | 310 | def delete_song!(options) 311 | songs.delete(songs[0]) and return true if options[:fragment]["title"] == "Broken, delete me!" 312 | false 313 | end 314 | end 315 | 316 | let(:form) { AlbumForm.new(album) } 317 | 318 | it do 319 | assert form.validate( 320 | "songs" => [{"title" => "Broken, delete me!"}, {"title" => "Roxanne"}] 321 | ) 322 | 323 | assert_equal form.errors.messages.inspect, "{}" 324 | 325 | assert_equal form.songs.size, 1 326 | assert_equal form.songs[0].title, "Roxanne" 327 | end 328 | end 329 | 330 | class PopulateWithFormKeyTest < MiniTest::Spec 331 | Song = Struct.new(:title, :album, :composer) 332 | Album = Struct.new(:name, :songs, :artist) 333 | 334 | let(:song) { Song.new('Broken') } 335 | let(:song2) { Song.new('Resist Stance') } 336 | let(:album) { Album.new('The Dissent Of Man', [song, song2]) } 337 | 338 | class SongForm < TestForm 339 | property :title 340 | 341 | validation do 342 | params { required(:title).filled } 343 | end 344 | end 345 | 346 | class AlbumForm < TestForm 347 | property :name 348 | 349 | collection :songs, form: SongForm, populator: :populator!, model_identifier: :title 350 | 351 | def populator!(fragment:, **) 352 | item = songs.find { |song| song.title == fragment['title'] } 353 | if item && fragment['delete'] == '1' 354 | songs.delete(item) 355 | return skip! 356 | end 357 | item || songs.append(Song.new) 358 | end 359 | end 360 | 361 | let(:form) { AlbumForm.new(album) } 362 | 363 | it do 364 | assert_equal 2, form.songs.size 365 | 366 | assert form.validate( 367 | 'songs' => [ 368 | { 'title' => 'Broken' }, 369 | { 'title' => 'Resist Stance' }, 370 | { 'title' => 'Rime Of The Ancient Mariner' } 371 | ] 372 | ) 373 | 374 | assert_equal 3, form.songs.size 375 | 376 | assert form.validate( 377 | 'songs' => [ 378 | { 'title' => 'Broken', 'delete' => '1' }, 379 | { 'title' => 'Resist Stance' }, 380 | { 'title' => 'Rime Of The Ancient Mariner' } 381 | ] 382 | ) 383 | assert_equal 2, form.songs.size 384 | assert_equal 'Resist Stance', form.songs.first.title 385 | assert_equal 'Rime Of The Ancient Mariner', form.songs.last.title 386 | end 387 | end 388 | -------------------------------------------------------------------------------- /test/populator_skip_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PopulatorSkipTest < MiniTest::Spec 4 | Album = Struct.new(:songs) 5 | Song = Struct.new(:title) 6 | 7 | class AlbumForm < TestForm 8 | collection :songs, populator: :my_populator do 9 | property :title 10 | end 11 | 12 | def my_populator(options) 13 | return skip! if options[:fragment][:title] == "Good" 14 | songs[options[:index]] 15 | end 16 | end 17 | 18 | it do 19 | form = AlbumForm.new(Album.new([Song.new, Song.new])) 20 | hash = {songs: [{title: "Good"}, {title: "Bad"}]} 21 | 22 | form.validate(hash) 23 | 24 | assert_equal form.songs.size, 2 25 | assert_nil form.songs[0].title 26 | assert_equal form.songs[1].title, "Bad" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/prepopulator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PrepopulatorTest < MiniTest::Spec 4 | Song = Struct.new(:title, :band, :length) 5 | Band = Struct.new(:name) 6 | 7 | class AlbumForm < TestForm 8 | property :title, prepopulator: ->(*) { self.title = "Another Day At Work" } # normal assignment. 9 | property :length 10 | 11 | property :hit, prepopulator: ->(options) { self.hit = Song.new(options[:title]) } do # use user options. 12 | property :title 13 | 14 | property :band, prepopulator: ->(options) { self.band = my_band(options[:title]) } do # invoke your own code. 15 | property :name 16 | end 17 | 18 | def my_band(name) 19 | Band.new(title) 20 | end 21 | end 22 | 23 | collection :songs, prepopulator: :prepopulate_songs! do 24 | property :title 25 | end 26 | 27 | private 28 | def prepopulate_songs!(options) 29 | if songs == nil 30 | self.songs = [Song.new, Song.new] 31 | else 32 | songs << Song.new # full Twin::Collection API available. 33 | end 34 | end 35 | end 36 | 37 | it do 38 | form = AlbumForm.new(OpenStruct.new(length: 1)).prepopulate!(title: "Potemkin City Limits") 39 | 40 | assert_equal form.length, 1 41 | assert_equal form.title, "Another Day At Work" 42 | assert_equal form.hit.model, Song.new("Potemkin City Limits") 43 | assert_equal form.songs.size, 2 44 | assert_equal form.songs[0].model, Song.new 45 | assert_equal form.songs[1].model, Song.new 46 | assert_equal form.songs[1].model, Song.new 47 | # prepopulate works more than 1 level, recursive. 48 | # it also passes options properly down there. 49 | assert_equal form.hit.band.model, Band.new("Potemkin City Limits") 50 | end 51 | 52 | # add to existing collection. 53 | it do 54 | form = AlbumForm.new(OpenStruct.new(songs: [Song.new])).prepopulate!(title: "Potemkin City Limits") 55 | 56 | assert_equal form.songs.size, 2 57 | assert_equal form.songs[0].model, Song.new 58 | assert_equal form.songs[1].model, Song.new 59 | end 60 | end 61 | 62 | # calling form.prepopulate! shouldn't crash. 63 | class PrepopulateWithoutConfiguration < MiniTest::Spec 64 | Song = Struct.new(:title) 65 | 66 | class AlbumForm < TestForm 67 | collection :songs do 68 | property :title 69 | end 70 | 71 | property :hit do 72 | property :title 73 | end 74 | end 75 | 76 | subject { AlbumForm.new(OpenStruct.new(songs: [], hit: nil)).prepopulate! } 77 | 78 | it { assert_equal subject.songs.size, 0 } 79 | end 80 | 81 | class ManualPrepopulatorOverridingTest < MiniTest::Spec 82 | Song = Struct.new(:title, :band, :length) 83 | Band = Struct.new(:name) 84 | 85 | class AlbumForm < TestForm 86 | property :title 87 | property :length 88 | 89 | property :hit do 90 | property :title 91 | 92 | property :band do 93 | property :name 94 | end 95 | end 96 | 97 | def prepopulate!(options) 98 | self.hit = Song.new(options[:title]) 99 | super 100 | end 101 | end 102 | 103 | # you can simply override Form#prepopulate! 104 | it do 105 | form = AlbumForm.new(OpenStruct.new(length: 1)).prepopulate!(title: "Potemkin City Limits") 106 | 107 | assert_equal form.length, 1 108 | assert_equal form.hit.model, Song.new("Potemkin City Limits") 109 | assert_equal form.hit.title, "Potemkin City Limits" 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/read_only_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ReadonlyTest < MiniTest::Spec 4 | class SongForm < TestForm 5 | property :artist 6 | property :title, writeable: false 7 | # TODO: what to do with virtual values? 8 | end 9 | 10 | let(:form) { SongForm.new(OpenStruct.new) } 11 | 12 | it { refute form.readonly?(:artist) } 13 | it { assert form.readonly?(:title) } 14 | end 15 | -------------------------------------------------------------------------------- /test/readable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ReadableTest < MiniTest::Spec 4 | Credentials = Struct.new(:password) 5 | 6 | class PasswordForm < TestForm 7 | property :password, readable: false 8 | end 9 | 10 | let(:cred) { Credentials.new } 11 | let(:form) { PasswordForm.new(cred) } 12 | 13 | it { 14 | assert_nil form.password # password not read. 15 | 16 | form.validate("password" => "123") 17 | 18 | assert_equal form.password, "123" 19 | 20 | form.sync 21 | assert_equal cred.password, "123" # password written. 22 | 23 | hash = {} 24 | form.save do |nested| 25 | hash = nested 26 | end 27 | 28 | assert_equal hash, "password" => "123" 29 | } 30 | end 31 | -------------------------------------------------------------------------------- /test/reform_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ReformTest < Minitest::Spec 4 | let(:comp) { OpenStruct.new(name: "Duran Duran", title: "Rio") } 5 | 6 | let(:form) { SongForm.new(comp) } 7 | 8 | class SongForm < TestForm 9 | property :name 10 | property :title 11 | 12 | validation do 13 | params { required(:name).filled } 14 | end 15 | end 16 | 17 | describe "(new) form with empty models" do 18 | let(:comp) { OpenStruct.new } 19 | 20 | it "returns empty fields" do 21 | assert_nil form.title 22 | assert_nil form.name 23 | end 24 | 25 | describe "and submitted values" do 26 | it "returns filled-out fields" do 27 | form.validate("name" => "Duran Duran") 28 | 29 | assert_nil form.title 30 | assert_equal form.name, "Duran Duran" 31 | end 32 | end 33 | end 34 | 35 | describe "(edit) form with existing models" do 36 | it "returns filled-out fields" do 37 | assert_equal form.name, "Duran Duran" 38 | assert_equal form.title, "Rio" 39 | end 40 | end 41 | 42 | describe "#validate" do 43 | let(:comp) { OpenStruct.new } 44 | 45 | it "ignores unmapped fields in input" do 46 | form.validate("name" => "Duran Duran", :genre => "80s") 47 | assert_raises NoMethodError do 48 | form.genre 49 | end 50 | end 51 | 52 | it "returns true when valid" do 53 | assert_equal form.validate("name" => "Duran Duran"), true 54 | end 55 | 56 | it "exposes input via property accessors" do 57 | form.validate("name" => "Duran Duran") 58 | 59 | assert_equal form.name, "Duran Duran" 60 | end 61 | 62 | it "doesn't change model properties" do 63 | form.validate("name" => "Duran Duran") 64 | 65 | assert_nil comp.name # don't touch model, yet. 66 | end 67 | 68 | # TODO: test errors. test valid. 69 | describe "invalid input" do 70 | class ValidatingForm < TestForm 71 | property :name 72 | property :title 73 | 74 | validation do 75 | params do 76 | required(:name).filled 77 | required(:title).filled 78 | end 79 | end 80 | end 81 | let(:form) { ValidatingForm.new(comp) } 82 | 83 | it "returns false when invalid" do 84 | assert_equal form.validate({}), false 85 | end 86 | 87 | it "populates errors" do 88 | form.validate({}) 89 | assert_equal form.errors.messages, name: ["must be filled"], title: ["must be filled"] 90 | end 91 | end 92 | end 93 | 94 | describe "#save" do 95 | let(:comp) { OpenStruct.new } 96 | let(:form) { SongForm.new(comp) } 97 | 98 | before { form.validate("name" => "Diesel Boy") } 99 | 100 | it "xxpushes data to models" do 101 | form.save 102 | 103 | assert_equal comp.name, "Diesel Boy" 104 | assert_nil comp.title 105 | end 106 | 107 | describe "#save with block" do 108 | it do 109 | hash = {} 110 | 111 | form.save do |map| 112 | hash = map 113 | end 114 | 115 | assert_equal hash, "name" => "Diesel Boy", "title" => nil 116 | end 117 | end 118 | end 119 | 120 | describe "#model" do 121 | it { assert_equal form.model, comp } 122 | end 123 | 124 | describe "inheritance" do 125 | class HitForm < SongForm 126 | property :position 127 | validation do 128 | params { required(:position).filled } 129 | end 130 | end 131 | 132 | let(:form) { HitForm.new(OpenStruct.new()) } 133 | it do 134 | form.validate("title" => "The Body") 135 | assert_equal form.title, "The Body" 136 | assert_nil form.position 137 | assert_equal form.errors.messages, name: ["must be filled"], position: ["must be filled"] 138 | end 139 | end 140 | end 141 | 142 | class OverridingAccessorsTest < BaseTest 143 | class SongForm < TestForm 144 | property :title 145 | 146 | def title=(v) # used in #validate. 147 | super v * 2 148 | end 149 | 150 | def title # used in #sync. 151 | super.downcase 152 | end 153 | end 154 | 155 | let(:song) { Song.new("Pray") } 156 | subject { SongForm.new(song) } 157 | 158 | # override reader for presentation. 159 | it { assert_equal subject.title, "pray" } 160 | 161 | describe "#save" do 162 | before { subject.validate("title" => "Hey Little World") } 163 | 164 | # reader always used 165 | it { assert_equal subject.title, "hey little worldhey little world" } 166 | 167 | # the reader is not used when saving/syncing. 168 | it do 169 | subject.save do |hash| 170 | assert_equal hash["title"], "Hey Little WorldHey Little World" 171 | end 172 | end 173 | 174 | # no reader or writer used when saving/syncing. 175 | it do 176 | song.extend(Saveable) 177 | subject.save 178 | assert_equal song.title, "Hey Little WorldHey Little World" 179 | end 180 | end 181 | end 182 | 183 | class MethodInFormTest < MiniTest::Spec 184 | class AlbumForm < TestForm 185 | property :title 186 | 187 | def title 188 | "The Suffer And The Witness" 189 | end 190 | 191 | property :hit do 192 | property :title 193 | 194 | def title 195 | "Drones" 196 | end 197 | end 198 | end 199 | 200 | # methods can be used instead of created accessors. 201 | subject { AlbumForm.new(OpenStruct.new(hit: OpenStruct.new)) } 202 | it { assert_equal subject.title, "The Suffer And The Witness" } 203 | it { assert_equal subject.hit.title, "Drones" } 204 | end 205 | -------------------------------------------------------------------------------- /test/save_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SaveTest < BaseTest 4 | Song = Struct.new(:title, :album, :composer) 5 | Album = Struct.new(:name, :songs, :artist) 6 | Artist = Struct.new(:name) 7 | 8 | class AlbumForm < TestForm 9 | property :name 10 | validation do 11 | params { required(:name).filled } 12 | end 13 | 14 | collection :songs do 15 | property :title 16 | validation do 17 | params { required(:title).filled } 18 | end 19 | 20 | property :composer do 21 | property :name 22 | validation do 23 | params { required(:name).filled } 24 | end 25 | end 26 | end 27 | 28 | property :artist, save: false do 29 | property :name 30 | end 31 | end 32 | 33 | module Saveable 34 | def save 35 | @saved = true 36 | end 37 | 38 | def saved? 39 | defined?(@saved) && @saved 40 | end 41 | end 42 | 43 | let(:song) { Song.new("Broken").extend(Saveable) } 44 | # let(:song_with_composer) { Song.new("Resist Stance", nil, composer).extend(Saveable) } 45 | let(:composer) { Artist.new("Greg Graffin").extend(Saveable) } 46 | let(:artist) { Artist.new("Bad Religion").extend(Saveable).extend(Saveable) } 47 | let(:album) { Album.new("The Dissent Of Man", [song], artist).extend(Saveable) } 48 | 49 | let(:form) { AlbumForm.new(album) } 50 | 51 | it do 52 | form.validate("songs" => [{"title" => "Fixed"}]) 53 | 54 | form.save 55 | 56 | assert album.saved? 57 | assert_equal album.songs[0].title, "Fixed" 58 | assert album.songs[0].saved? 59 | assert_nil album.artist.saved? 60 | end 61 | 62 | describe "#sync with block" do 63 | it do 64 | form = AlbumForm.new(Album.new("Greatest Hits")) 65 | 66 | form.validate(name: nil) # nil-out the title. 67 | 68 | nested_hash = nil 69 | form.sync do |hash| 70 | nested_hash = hash 71 | end 72 | 73 | assert_equal nested_hash, "name" => nil, "artist" => nil 74 | end 75 | end 76 | end 77 | 78 | # class SaveWithDynamicOptionsTest < MiniTest::Spec 79 | # Song = Struct.new(:id, :title, :length) do 80 | # include Saveable 81 | # end 82 | 83 | # class SongForm < TestForm 84 | # property :title#, save: false 85 | # property :length, virtual: true 86 | # end 87 | 88 | # let(:song) { Song.new } 89 | # let(:form) { SongForm.new(song) } 90 | 91 | # # we have access to original input value and outside parameters. 92 | # it "xxx" do 93 | # form.validate("title" => "A Poor Man's Memory", "length" => 10) 94 | # length_seconds = 120 95 | # form.save(length: lambda { |value, options| form.model.id = "#{value}: #{length_seconds}" }) 96 | 97 | # song.title.must_equal "A Poor Man's Memory" 98 | # assert_nil song.length 99 | # song.id.must_equal "10: 120" 100 | # end 101 | # end 102 | -------------------------------------------------------------------------------- /test/setup_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SetupTest < MiniTest::Spec 4 | Song = Struct.new(:title, :album, :composer) 5 | Album = Struct.new(:name, :songs, :artist) 6 | Artist = Struct.new(:name) 7 | 8 | class AlbumForm < TestForm 9 | property :name 10 | collection :songs do 11 | property :title 12 | 13 | property :composer do 14 | property :name 15 | end 16 | end 17 | 18 | property :artist do 19 | property :name 20 | end 21 | end 22 | 23 | let(:song) { Song.new("Broken") } 24 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 25 | let(:composer) { Artist.new("Greg Graffin") } 26 | let(:artist) { Artist.new("Bad Religion") } 27 | 28 | describe "with nested objects" do 29 | let(:album) { Album.new("The Dissent Of Man", [song, song_with_composer], artist) } 30 | 31 | it do 32 | form = AlbumForm.new(album) 33 | 34 | assert_equal form.name, "The Dissent Of Man" 35 | assert_equal form.songs[0].title, "Broken" 36 | assert_nil form.songs[0].composer 37 | assert_equal form.songs[1].title, "Resist Stance" 38 | assert_equal form.songs[1].composer.name, "Greg Graffin" 39 | assert_equal form.artist.name, "Bad Religion" 40 | 41 | # make sure all is wrapped in forms. 42 | assert form.songs[0].is_a? Reform::Form 43 | assert form.songs[1].is_a? Reform::Form 44 | assert form.songs[1].composer.is_a? Reform::Form 45 | assert form.artist.is_a? Reform::Form 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/skip_if_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SkipIfTest < BaseTest 4 | let(:hit) { Song.new } 5 | let(:album) { Album.new(nil, hit, [], nil) } 6 | 7 | class AlbumForm < TestForm 8 | property :title 9 | 10 | property :hit, skip_if: ->(options) { options[:fragment]["title"] == "" } do 11 | property :title 12 | validation do 13 | params { required(:title).filled } 14 | end 15 | end 16 | 17 | collection :songs, skip_if: :skip_song?, populate_if_empty: BaseTest::Song do 18 | property :title 19 | end 20 | 21 | def skip_song?(options) 22 | options[:fragment]["title"].nil? 23 | end 24 | end 25 | 26 | # deserializes when present. 27 | it do 28 | form = AlbumForm.new(album) 29 | assert form.validate("hit" => {"title" => "Altar Of Sacrifice"}) 30 | assert_equal form.hit.title, "Altar Of Sacrifice" 31 | end 32 | 33 | # skips deserialization when not present. 34 | it do 35 | form = AlbumForm.new(Album.new) 36 | assert form.validate("hit" => {"title" => ""}) 37 | assert_nil form.hit # hit hasn't been deserialised. 38 | end 39 | 40 | # skips deserialization when not present. 41 | it do 42 | form = AlbumForm.new(Album.new(nil, nil, [])) 43 | assert form.validate("songs" => [{"title" => "Waste Of Breath"}, {"title" => nil}]) 44 | assert_equal form.songs.size, 1 45 | assert_equal form.songs[0].title, "Waste Of Breath" 46 | end 47 | end 48 | 49 | class SkipIfAllBlankTest < BaseTest 50 | # skip_if: :all_blank" 51 | class AlbumForm < TestForm 52 | collection :songs, skip_if: :all_blank, populate_if_empty: BaseTest::Song do 53 | property :title 54 | property :length 55 | end 56 | end 57 | 58 | # create only one object. 59 | it do 60 | form = AlbumForm.new(OpenStruct.new(songs: [])) 61 | assert form.validate("songs" => [{"title" => "Apathy"}, {"title" => "", "length" => ""}]) 62 | assert_equal form.songs.size, 1 63 | assert_equal form.songs[0].title, "Apathy" 64 | end 65 | 66 | it do 67 | form = AlbumForm.new(OpenStruct.new(songs: [])) 68 | assert form.validate("songs" => [{"title" => "", "length" => ""}, {"title" => "Apathy"}]) 69 | assert_equal form.songs.size, 1 70 | assert_equal form.songs[0].title, "Apathy" 71 | end 72 | end 73 | 74 | class InvalidOptionsCombinationTest < BaseTest 75 | it do 76 | assert_raises(Reform::Form::InvalidOptionsCombinationError) do 77 | class AlbumForm < TestForm 78 | collection :songs, skip_if: :all_blank, populator: -> {} do 79 | property :title 80 | property :length 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/skip_setter_and_getter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | # Overridden setter won't be called in setup. 4 | # Overridden getter won't be called in sync. 5 | class SetupSkipSetterAndGetterTest < MiniTest::Spec 6 | Song = Struct.new(:title, :album, :composer) 7 | Album = Struct.new(:title, :artist) 8 | Artist = Struct.new(:name) 9 | 10 | class AlbumForm < TestForm 11 | property :title 12 | 13 | def title 14 | super.upcase 15 | end 16 | 17 | def title=(v) 18 | super v.reverse 19 | end 20 | 21 | property :artist do 22 | property :name 23 | 24 | def name 25 | super.downcase 26 | end 27 | 28 | def name=(v) 29 | super v.chop 30 | end 31 | end 32 | end 33 | 34 | let(:artist) { Artist.new("Bad Religion") } 35 | 36 | it do 37 | album = Album.new("Greatest Hits", artist) 38 | form = AlbumForm.new(album) 39 | 40 | assert_equal form.title, "GREATEST HITS" 41 | assert_equal form.artist.name, "bad religion" 42 | 43 | form.validate("title" => "Resiststance", "artist" => {"name" => "Greg Graffin"}) 44 | 45 | assert_equal form.title, "ECNATSTSISER" # first, setter called, then getter. 46 | assert_equal form.artist.name, "greg graffi" 47 | 48 | form.sync 49 | 50 | assert_equal album.title, "ecnatstsiseR" # setter called, but not getter. 51 | assert_equal album.artist.name, "Greg Graffi" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "reform" 2 | require "minitest/autorun" 3 | require "representable/debug" 4 | require "declarative/testing" 5 | require "pp" 6 | 7 | require "reform/form/dry" 8 | 9 | # setup test classes so we can test without dry being included 10 | class TestForm < Reform::Form 11 | feature Reform::Form::Dry 12 | end 13 | 14 | class TestContract < Reform::Contract 15 | feature Reform::Form::Dry 16 | end 17 | 18 | module Types 19 | include Dry.Types() 20 | end 21 | 22 | class BaseTest < MiniTest::Spec 23 | class AlbumForm < TestForm 24 | property :title 25 | 26 | property :hit do 27 | property :title 28 | end 29 | 30 | collection :songs do 31 | property :title 32 | end 33 | end 34 | 35 | Song = Struct.new(:title, :length, :rating) 36 | Album = Struct.new(:title, :hit, :songs, :band) 37 | Band = Struct.new(:label) 38 | Label = Struct.new(:name) 39 | Length = Struct.new(:minutes, :seconds) 40 | 41 | let(:hit) { Song.new("Roxanne") } 42 | end 43 | 44 | MiniTest::Spec.class_eval do 45 | Song = Struct.new(:title, :album, :composer) 46 | Album = Struct.new(:name, :songs, :artist) 47 | Artist = Struct.new(:name) 48 | 49 | module Saveable 50 | def save 51 | @saved = true 52 | end 53 | 54 | def saved? 55 | @saved 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/validate_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ContractValidateTest < MiniTest::Spec 4 | class AlbumForm < TestContract 5 | property :name 6 | validation do 7 | params { required(:name).filled } 8 | end 9 | 10 | collection :songs do 11 | property :title 12 | validation do 13 | params { required(:title).filled } 14 | end 15 | 16 | property :composer do 17 | validation do 18 | params { required(:name).filled } 19 | end 20 | property :name 21 | end 22 | end 23 | 24 | property :artist do 25 | property :name 26 | end 27 | end 28 | 29 | let(:song) { Song.new("Broken") } 30 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 31 | let(:composer) { Artist.new("Greg Graffin") } 32 | let(:artist) { Artist.new("Bad Religion") } 33 | let(:album) { Album.new("The Dissent Of Man", [song, song_with_composer], artist) } 34 | 35 | let(:form) { AlbumForm.new(album) } 36 | 37 | # valid 38 | it do 39 | assert form.validate 40 | assert_equal form.errors.messages.inspect, "{}" 41 | end 42 | 43 | # invalid 44 | it do 45 | album.songs[1].composer.name = nil 46 | album.name = nil 47 | 48 | assert_equal form.validate, false 49 | assert_equal form.errors.messages.inspect, "{:name=>[\"must be filled\"], :\"songs.composer.name\"=>[\"must be filled\"]}" 50 | end 51 | end 52 | 53 | # no configuration results in "sync" (formerly known as parse_strategy: :sync). 54 | class ValidateWithoutConfigurationTest < MiniTest::Spec 55 | class AlbumForm < TestForm 56 | property :name 57 | validation do 58 | params { required(:name).filled } 59 | end 60 | 61 | collection :songs do 62 | property :title 63 | validation do 64 | params { required(:title).filled } 65 | end 66 | 67 | property :composer do 68 | property :name 69 | validation do 70 | params { required(:name).filled } 71 | end 72 | end 73 | end 74 | 75 | property :artist do 76 | property :name 77 | end 78 | end 79 | 80 | let(:song) { Song.new("Broken") } 81 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 82 | let(:composer) { Artist.new("Greg Graffin") } 83 | let(:artist) { Artist.new("Bad Religion") } 84 | let(:album) { Album.new("The Dissent Of Man", [song, song_with_composer], artist) } 85 | 86 | let(:form) { AlbumForm.new(album) } 87 | 88 | # valid. 89 | it do 90 | object_ids = { 91 | song: form.songs[0].object_id, song_with_composer: form.songs[1].object_id, 92 | artist: form.artist.object_id, composer: form.songs[1].composer.object_id 93 | } 94 | 95 | assert form.validate( 96 | "name" => "Best Of", 97 | "songs" => [{"title" => "Fallout"}, {"title" => "Roxanne", "composer" => {"name" => "Sting"}}], 98 | "artist" => {"name" => "The Police"} 99 | ) 100 | 101 | assert_equal form.errors.messages.inspect, "{}" 102 | 103 | # form has updated. 104 | assert_equal form.name, "Best Of" 105 | assert_equal form.songs[0].title, "Fallout" 106 | assert_equal form.songs[1].title, "Roxanne" 107 | assert_equal form.songs[1].composer.name, "Sting" 108 | assert_equal form.artist.name, "The Police" 109 | 110 | # objects are still the same. 111 | assert_equal form.songs[0].object_id, object_ids[:song] 112 | assert_equal form.songs[1].object_id, object_ids[:song_with_composer] 113 | assert_equal form.songs[1].composer.object_id, object_ids[:composer] 114 | assert_equal form.artist.object_id, object_ids[:artist] 115 | 116 | # model has not changed, yet. 117 | assert_equal album.name, "The Dissent Of Man" 118 | assert_equal album.songs[0].title, "Broken" 119 | assert_equal album.songs[1].title, "Resist Stance" 120 | assert_equal album.songs[1].composer.name, "Greg Graffin" 121 | assert_equal album.artist.name, "Bad Religion" 122 | end 123 | 124 | # with symbols. 125 | it do 126 | assert form.validate( 127 | name: "Best Of", 128 | songs: [{title: "The X-Creep"}, {title: "Trudging", composer: {name: "SNFU"}}], 129 | artist: {name: "The Police"} 130 | ) 131 | 132 | assert_equal form.name, "Best Of" 133 | assert_equal form.songs[0].title, "The X-Creep" 134 | assert_equal form.songs[1].title, "Trudging" 135 | assert_equal form.songs[1].composer.name, "SNFU" 136 | assert_equal form.artist.name, "The Police" 137 | end 138 | 139 | # throws exception when no populators. 140 | it do 141 | album = Album.new("The Dissent Of Man", []) 142 | 143 | assert_raises RuntimeError do 144 | AlbumForm.new(album).validate(songs: {title: "Resist-Stance"}) 145 | end 146 | end 147 | end 148 | 149 | class ValidateWithInternalPopulatorOptionTest < MiniTest::Spec 150 | class AlbumForm < TestForm 151 | property :name 152 | validation do 153 | params { required(:name).filled } 154 | end 155 | 156 | collection :songs, 157 | internal_populator: ->(input, options) { 158 | collection = options[:represented].songs 159 | (item = collection[options[:index]]) ? item : collection.insert(options[:index], Song.new) 160 | } do 161 | property :title 162 | validation do 163 | params { required(:title).filled } 164 | end 165 | 166 | property :composer, internal_populator: ->(input, options) { (item = options[:represented].composer) ? item : Artist.new } do 167 | property :name 168 | validation do 169 | params { required(:name).filled } 170 | end 171 | end 172 | end 173 | 174 | property :artist, internal_populator: ->(input, options) { (item = options[:represented].artist) ? item : Artist.new } do 175 | property :name 176 | validation do 177 | params { required(:name).filled } 178 | end 179 | end 180 | end 181 | 182 | let(:song) { Song.new("Broken") } 183 | let(:song_with_composer) { Song.new("Resist Stance", nil, composer) } 184 | let(:composer) { Artist.new("Greg Graffin") } 185 | let(:artist) { Artist.new("Bad Religion") } 186 | let(:album) { Album.new("The Dissent Of Man", [song, song_with_composer], artist) } 187 | 188 | let(:form) { AlbumForm.new(album) } 189 | 190 | # valid. 191 | it("xxx") do 192 | assert form.validate( 193 | "name" => "Best Of", 194 | "songs" => [{"title" => "Fallout"}, {"title" => "Roxanne", "composer" => {"name" => "Sting"}}], 195 | "artist" => {"name" => "The Police"} 196 | ) 197 | 198 | assert_equal form.errors.messages.inspect, "{}" 199 | 200 | # form has updated. 201 | assert_equal form.name, "Best Of" 202 | assert_equal form.songs[0].title, "Fallout" 203 | assert_equal form.songs[1].title, "Roxanne" 204 | assert_equal form.songs[1].composer.name, "Sting" 205 | assert_equal form.artist.name, "The Police" 206 | 207 | # model has not changed, yet. 208 | assert_equal album.name, "The Dissent Of Man" 209 | assert_equal album.songs[0].title, "Broken" 210 | assert_equal album.songs[1].title, "Resist Stance" 211 | assert_equal album.songs[1].composer.name, "Greg Graffin" 212 | assert_equal album.artist.name, "Bad Religion" 213 | end 214 | 215 | # invalid. 216 | it do 217 | assert_equal form.validate( 218 | "name" => "", 219 | "songs" => [{"title" => "Fallout"}, {"title" => "Roxanne", "composer" => {"name" => ""}}], 220 | "artist" => {"name" => ""}, 221 | ), false 222 | 223 | assert_equal form.errors.messages.inspect, "{:name=>[\"must be filled\"], :\"songs.composer.name\"=>[\"must be filled\"], :\"artist.name\"=>[\"must be filled\"]}" 224 | end 225 | 226 | # adding to collection via :instance. 227 | # valid. 228 | it do 229 | assert form.validate( 230 | "songs" => [{"title" => "Fallout"}, {"title" => "Roxanne"}, {"title" => "Rime Of The Ancient Mariner"}] 231 | ) 232 | 233 | assert_equal form.errors.messages.inspect, "{}" 234 | 235 | # form has updated. 236 | assert_equal form.name, "The Dissent Of Man" 237 | assert_equal form.songs[0].title, "Fallout" 238 | assert_equal form.songs[1].title, "Roxanne" 239 | assert_equal form.songs[1].composer.name, "Greg Graffin" 240 | assert_equal form.songs[1].title, "Roxanne" 241 | assert_equal form.songs[2].title, "Rime Of The Ancient Mariner" # new song added. 242 | assert_equal form.songs.size, 3 243 | assert_equal form.artist.name, "Bad Religion" 244 | 245 | # model has not changed, yet. 246 | assert_equal album.name, "The Dissent Of Man" 247 | assert_equal album.songs[0].title, "Broken" 248 | assert_equal album.songs[1].title, "Resist Stance" 249 | assert_equal album.songs[1].composer.name, "Greg Graffin" 250 | assert_equal album.songs.size, 2 251 | assert_equal album.artist.name, "Bad Religion" 252 | end 253 | 254 | # allow writeable: false even in the deserializer. 255 | class SongForm < TestForm 256 | property :title, deserializer: {writeable: false} 257 | end 258 | 259 | it do 260 | form = SongForm.new(song = Song.new) 261 | form.validate("title" => "Ignore me!") 262 | assert_nil form.title 263 | form.title = "Unopened" 264 | form.sync # only the deserializer is marked as not-writeable. 265 | assert_equal song.title, "Unopened" 266 | end 267 | end 268 | 269 | # memory leak test 270 | class ValidateUsingDifferentFormObject < MiniTest::Spec 271 | class AlbumForm < TestForm 272 | property :name 273 | 274 | validation do 275 | option :form 276 | 277 | params { required(:name).filled(:str?) } 278 | 279 | rule(:name) do 280 | if form.name == 'invalid' 281 | key.failure('Invalid name') 282 | end 283 | end 284 | end 285 | end 286 | 287 | let(:album) { Album.new } 288 | 289 | let(:form) { AlbumForm.new(album) } 290 | 291 | it 'sets name correctly' do 292 | assert form.validate(name: 'valid') 293 | form.sync 294 | assert_equal form.model.name, 'valid' 295 | end 296 | 297 | it 'validates presence of name' do 298 | refute form.validate(name: nil) 299 | assert_equal form.errors[:name], ["must be filled"] 300 | end 301 | 302 | it 'validates type of name' do 303 | refute form.validate(name: 1) 304 | assert_equal form.errors[:name], ["must be a string"] 305 | end 306 | 307 | it 'when name is invalid' do 308 | refute form.validate(name: 'invalid') 309 | assert_equal form.errors[:name], ["Invalid name"] 310 | end 311 | end 312 | 313 | # # not sure if we should catch that in Reform or rather do that in disposable. this is https://github.com/trailblazer/reform/pull/104 314 | # # describe ":populator with :empty" do 315 | # # let(:form) { 316 | # # Class.new(Reform::Form) do 317 | # # collection :songs, :empty => true, :populator => lambda { |fragment, index, args| 318 | # # songs[index] = args.binding[:form].new(Song.new) 319 | # # } do 320 | # # property :title 321 | # # end 322 | # # end 323 | # # } 324 | 325 | # # let(:params) { 326 | # # { 327 | # # "songs" => [{"title" => "Fallout"}, {"title" => "Roxanne"}] 328 | # # } 329 | # # } 330 | 331 | # # subject { form.new(Album.new("Hits", [], [])) } 332 | 333 | # # before { subject.validate(params) } 334 | 335 | # # it { subject.songs[0].title.must_equal "Fallout" } 336 | # # it { subject.songs[1].title.must_equal "Roxanne" } 337 | # # end 338 | 339 | # # test cardinalities. 340 | # describe "with empty collection and cardinality" do 341 | # let(:album) { Album.new } 342 | 343 | # subject { Class.new(Reform::Form) do 344 | # include Reform::Form::ActiveModel 345 | # model :album 346 | 347 | # collection :songs do 348 | # property :title 349 | # end 350 | 351 | # property :hit do 352 | # property :title 353 | # end 354 | 355 | # validates :songs, :length => {:minimum => 1} 356 | # validates :hit, :presence => true 357 | # end.new(album) } 358 | 359 | # describe "invalid" do 360 | # before { subject.validate({}).must_equal false } 361 | 362 | # it do 363 | # # ensure that only hit and songs keys are present 364 | # subject.errors.messages.keys.sort.must_equal([:hit, :songs]) 365 | # # validate content of hit and songs keys 366 | # subject.errors.messages[:hit].must_equal(["must be filled"]) 367 | # subject.errors.messages[:songs].first.must_match(/\Ais too short \(minimum is 1 characters?\)\z/) 368 | # end 369 | # end 370 | 371 | # describe "valid" do 372 | # let(:album) { Album.new(nil, Song.new, [Song.new("Urban Myth")]) } 373 | 374 | # before { 375 | # subject.validate({"songs" => [{"title"=>"Daddy, Brother, Lover, Little Boy"}], "hit" => {"title"=>"The Horse"}}). 376 | # must_equal true 377 | # } 378 | 379 | # it { subject.errors.messages.must_equal({}) } 380 | # end 381 | # end 382 | 383 | # # providing manual validator method allows accessing form's API. 384 | # describe "with ::validate" do 385 | # let(:form) { 386 | # Class.new(Reform::Form) do 387 | # property :title 388 | 389 | # validate :title? 390 | 391 | # def title? 392 | # errors.add :title, "not lowercase" if title == "Fallout" 393 | # end 394 | # end 395 | # } 396 | 397 | # let(:params) { {"title" => "Fallout"} } 398 | # let(:song) { Song.new("Englishman") } 399 | 400 | # subject { form.new(song) } 401 | 402 | # before { @res = subject.validate(params) } 403 | 404 | # it { @res.must_equal false } 405 | # it { subject.errors.messages.must_equal({:title=>["not lowercase"]}) } 406 | # end 407 | 408 | # # overriding the reader for a nested form should only be considered when rendering. 409 | # describe "with overridden reader for nested form" do 410 | # let(:form) { 411 | # Class.new(Reform::Form) do 412 | # property :band, :populate_if_empty => lambda { |*| Band.new } do 413 | # property :label 414 | # end 415 | 416 | # collection :songs, :populate_if_empty => lambda { |*| Song.new } do 417 | # property :title 418 | # end 419 | 420 | # def band 421 | # raise "only call me when rendering the form!" 422 | # end 423 | 424 | # def songs 425 | # raise "only call me when rendering the form!" 426 | # end 427 | # end.new(album) 428 | # } 429 | 430 | # let(:album) { Album.new } 431 | 432 | # # don't use #artist when validating! 433 | # it do 434 | # form.validate("band" => {"label" => "Hellcat"}, "songs" => [{"title" => "Stand Your Ground"}, {"title" => "Otherside"}]) 435 | # form.sync 436 | # album.band.label.must_equal "Hellcat" 437 | # album.songs.first.title.must_equal "Stand Your Ground" 438 | # end 439 | # end 440 | # end 441 | -------------------------------------------------------------------------------- /test/validation/dry_validation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "reform/form/dry" 3 | require "reform/form/coercion" 4 | #--- 5 | # one "nested" Schema per form. 6 | class DryValidationErrorsAPITest < Minitest::Spec 7 | Album = Struct.new(:title, :artist, :songs) 8 | Song = Struct.new(:title) 9 | Artist = Struct.new(:email, :label) 10 | Label = Struct.new(:location) 11 | 12 | class AlbumForm < TestForm 13 | property :title 14 | 15 | validation do 16 | params do 17 | required(:title).filled(min_size?: 2) 18 | end 19 | end 20 | 21 | property :artist do 22 | property :email 23 | 24 | validation do 25 | params { required(:email).filled } 26 | end 27 | 28 | property :label do 29 | property :location 30 | 31 | validation do 32 | params { required(:location).filled } 33 | end 34 | end 35 | end 36 | 37 | # note the validation block is *in* the collection block, per item, so to speak. 38 | collection :songs do 39 | property :title 40 | 41 | validation do 42 | config.messages.load_paths << "test/fixtures/dry_error_messages.yml" 43 | 44 | params { required(:title).filled } 45 | end 46 | end 47 | end 48 | 49 | let(:form) { AlbumForm.new(Album.new(nil, Artist.new(nil, Label.new), [Song.new(nil), Song.new(nil)])) } 50 | 51 | it "everything wrong" do 52 | result = form.(title: nil, artist: {email: ""}, songs: [{title: "Clams have feelings too"}, {title: ""}]) 53 | 54 | assert_equal result.success?, false 55 | 56 | assert_equal form.errors.messages, title: ["must be filled", "size cannot be less than 2"], "artist.email": ["must be filled"], "artist.label.location": ["must be filled"], "songs.title": ["must be filled"] 57 | assert_equal form.artist.errors.messages, email: ["must be filled"], "label.location": ["must be filled"] 58 | assert_equal form.artist.label.errors.messages, location: ["must be filled"] 59 | assert_equal form.songs[0].errors.messages, {} 60 | assert_equal form.songs[1].errors.messages, title: ["must be filled"] 61 | 62 | # #errors[] 63 | assert_equal form.errors[:nonsense], [] 64 | assert_equal form.errors[:title], ["must be filled", "size cannot be less than 2"] 65 | assert_equal form.artist.errors[:email], ["must be filled"] 66 | assert_equal form.artist.label.errors[:location], ["must be filled"] 67 | assert_equal form.songs[0].errors[:title], [] 68 | assert_equal form.songs[1].errors[:title], ["must be filled"] 69 | 70 | # #to_result 71 | assert_equal form.to_result.errors, title: ["must be filled"] 72 | assert_equal form.to_result.messages, title: ["must be filled", "size cannot be less than 2"] 73 | assert_equal form.to_result.hints, title: ["size cannot be less than 2"] 74 | assert_equal form.artist.to_result.errors, email: ["must be filled"] 75 | assert_equal form.artist.to_result.messages, email: ["must be filled"] 76 | assert_equal form.artist.to_result.hints, {} 77 | assert_equal form.artist.label.to_result.errors, location: ["must be filled"] 78 | assert_equal form.artist.label.to_result.messages, location: ["must be filled"] 79 | assert_equal form.artist.label.to_result.hints, {} 80 | assert_equal form.songs[0].to_result.errors, {} 81 | assert_equal form.songs[0].to_result.messages, {} 82 | assert_equal form.songs[0].to_result.hints, {} 83 | assert_equal form.songs[1].to_result.errors, title: ["must be filled"] 84 | assert_equal form.songs[1].to_result.messages, title: ["must be filled"] 85 | assert_equal form.songs[1].to_result.hints, {} 86 | assert_equal form.songs[1].to_result.errors(locale: :de), title: ["muss abgefüllt sein"] 87 | # seems like dry-v when calling Dry::Schema::Result#messages locale option is ignored 88 | # started a topic in their forum https://discourse.dry-rb.org/t/dry-result-messages-ignore-locale-option/910 89 | # assert_equal form.songs[1].to_result.messages(locale: :de), (title: ["muss abgefüllt sein"]) 90 | assert_equal form.songs[1].to_result.hints(locale: :de), ({}) 91 | end 92 | 93 | it "only nested property is invalid." do 94 | result = form.(title: "Black Star", artist: {email: ""}) 95 | 96 | assert_equal result.success?, false 97 | 98 | # errors.messages 99 | assert_equal form.errors.messages, "artist.email": ["must be filled"], "artist.label.location": ["must be filled"], "songs.title": ["must be filled"] 100 | assert_equal form.artist.errors.messages, email: ["must be filled"], "label.location": ["must be filled"] 101 | assert_equal form.artist.label.errors.messages, location: ["must be filled"] 102 | end 103 | 104 | it "nested collection invalid" do 105 | result = form.(title: "Black Star", artist: {email: "uhm", label: {location: "Hannover"}}, songs: [{title: ""}]) 106 | 107 | assert_equal result.success?, false 108 | assert_equal form.errors.messages, "songs.title": ["must be filled"] 109 | end 110 | 111 | #--- 112 | #- validation .each 113 | class CollectionExternalValidationsForm < TestForm 114 | collection :songs do 115 | property :title 116 | end 117 | 118 | validation do 119 | params do 120 | required(:songs).each do 121 | schema do 122 | required(:title).filled 123 | end 124 | end 125 | end 126 | end 127 | end 128 | 129 | it do 130 | form = CollectionExternalValidationsForm.new(Album.new(nil, nil, [Song.new, Song.new])) 131 | form.validate(songs: [{title: "Liar"}, {title: ""}]) 132 | 133 | assert_equal form.errors.messages, "songs.title": ["must be filled"] 134 | assert_equal form.songs[0].errors.messages, {} 135 | assert_equal form.songs[1].errors.messages, title: ["must be filled"] 136 | end 137 | end 138 | 139 | class DryValidationExplicitSchemaTest < Minitest::Spec 140 | Session = Struct.new(:name, :email) 141 | SessionContract = Dry::Validation.Contract do 142 | params do 143 | required(:name).filled 144 | required(:email).filled 145 | end 146 | end 147 | 148 | class SessionForm < TestForm 149 | include Coercion 150 | 151 | property :name 152 | property :email 153 | 154 | validation contract: SessionContract 155 | end 156 | 157 | let(:form) { SessionForm.new(Session.new) } 158 | 159 | # valid. 160 | it do 161 | assert form.validate(name: "Helloween", email: "yep") 162 | assert_equal form.errors.messages.inspect, "{}" 163 | end 164 | 165 | it "invalid" do 166 | assert_equal form.validate(name: "", email: "yep"), false 167 | assert_equal form.errors.messages.inspect, "{:name=>[\"must be filled\"]}" 168 | end 169 | end 170 | 171 | class DryValidationDefaultGroupTest < Minitest::Spec 172 | Session = Struct.new(:username, :email, :password, :confirm_password, :starts_at, :active, :color) 173 | 174 | class SessionForm < TestForm 175 | include Coercion 176 | 177 | property :username 178 | property :email 179 | property :password 180 | property :confirm_password 181 | property :starts_at, type: Types::Params::DateTime 182 | property :active, type: Types::Params::Bool 183 | property :color 184 | 185 | validation do 186 | params do 187 | required(:username).filled 188 | required(:email).filled 189 | required(:starts_at).filled(:date_time?) 190 | required(:active).filled(:bool?) 191 | end 192 | end 193 | 194 | validation name: :another_block do 195 | params { required(:confirm_password).filled } 196 | end 197 | 198 | validation name: :dynamic_args do 199 | option :form 200 | params { optional(:color) } 201 | rule(:color) do 202 | if value 203 | key.failure("must be one of: #{form.colors}") unless form.colors.include? value 204 | end 205 | end 206 | end 207 | 208 | def colors 209 | %(red orange green) 210 | end 211 | end 212 | 213 | let(:form) { SessionForm.new(Session.new) } 214 | 215 | # valid. 216 | it do 217 | assert form.validate( 218 | username: "Helloween", 219 | email: "yep", 220 | starts_at: "01/01/2000 - 11:00", 221 | active: "true", 222 | confirm_password: "pA55w0rd" 223 | ) 224 | assert form.active 225 | assert_equal "{}", form.errors.messages.inspect 226 | end 227 | 228 | it "invalid" do 229 | assert_equal form.validate( 230 | username: "Helloween", 231 | email: "yep", 232 | active: "1", 233 | starts_at: "01/01/2000 - 11:00", 234 | color: "purple" 235 | ), false 236 | assert form.active 237 | assert_equal form.errors.messages.inspect, "{:confirm_password=>[\"must be filled\"], :color=>[\"must be one of: red orange green\"]}" 238 | end 239 | end 240 | 241 | class ValidationGroupsTest < MiniTest::Spec 242 | describe "basic validations" do 243 | Session = Struct.new(:username, :email, :password, :confirm_password, :special_class) 244 | SomeClass = Struct.new(:id) 245 | 246 | class SessionForm < TestForm 247 | property :username 248 | property :email 249 | property :password 250 | property :confirm_password 251 | property :special_class 252 | 253 | validation do 254 | params do 255 | required(:username).filled 256 | required(:email).filled 257 | required(:special_class).filled(type?: SomeClass) 258 | end 259 | end 260 | 261 | validation name: :email, if: :default do 262 | params { required(:email).filled(min_size?: 3) } 263 | end 264 | 265 | validation name: :password, if: :email do 266 | params { required(:password).filled(min_size?: 2) } 267 | end 268 | 269 | validation name: :confirm, if: :default, after: :email do 270 | params { required(:confirm_password).filled(min_size?: 2) } 271 | end 272 | end 273 | 274 | let(:form) { SessionForm.new(Session.new) } 275 | 276 | # valid. 277 | it do 278 | assert form.validate( 279 | username: "Helloween", 280 | special_class: SomeClass.new(id: 15), 281 | email: "yep", 282 | password: "99", 283 | confirm_password: "99" 284 | ) 285 | assert_equal form.errors.messages.inspect, "{}" 286 | end 287 | 288 | # invalid. 289 | it do 290 | assert_equal form.validate({}), false 291 | assert_equal form.errors.messages, username: ["must be filled"], email: ["must be filled"], special_class: ["must be filled", "must be ValidationGroupsTest::SomeClass"] 292 | end 293 | 294 | # partially invalid. 295 | # 2nd group fails. 296 | it do 297 | assert_equal form.validate(username: "Helloween", email: "yo", confirm_password: "9", special_class: SomeClass.new(id: 15)), false 298 | assert_equal form.errors.messages.inspect, "{:email=>[\"size cannot be less than 3\"], :confirm_password=>[\"size cannot be less than 2\"]}" 299 | end 300 | # 3rd group fails. 301 | it do 302 | assert_equal form.validate(username: "Helloween", email: "yo!", confirm_password: "9", special_class: SomeClass.new(id: 15)), false 303 | assert_equal form.errors.messages.inspect, "{:confirm_password=>[\"size cannot be less than 2\"], :password=>[\"must be filled\", \"size cannot be less than 2\"]}" 304 | end 305 | # 4th group with after: fails. 306 | it do 307 | assert_equal form.validate(username: "Helloween", email: "yo!", password: "1", confirm_password: "9", special_class: SomeClass.new(id: 15)), false 308 | assert_equal form.errors.messages.inspect, "{:confirm_password=>[\"size cannot be less than 2\"], :password=>[\"size cannot be less than 2\"]}" 309 | end 310 | end 311 | 312 | class ValidationWithOptionsTest < MiniTest::Spec 313 | describe "basic validations" do 314 | Session = Struct.new(:username) 315 | class SessionForm < TestForm 316 | property :username 317 | 318 | validation name: :default, with: {user: OpenStruct.new(name: "Nick")} do 319 | option :user 320 | params do 321 | required(:username).filled 322 | end 323 | rule(:username) do 324 | key.failure("must be equal to #{user.name}") unless user.name == value 325 | end 326 | end 327 | end 328 | 329 | let(:form) { SessionForm.new(Session.new) } 330 | 331 | # valid. 332 | it do 333 | assert form.validate(username: "Nick") 334 | assert_equal form.errors.messages.inspect, "{}" 335 | end 336 | 337 | # invalid. 338 | it do 339 | assert_equal form.validate(username: "Fred"), false 340 | assert_equal form.errors.messages.inspect, "{:username=>[\"must be equal to Nick\"]}" 341 | end 342 | end 343 | end 344 | 345 | #--- 346 | describe "with custom schema" do 347 | Session2 = Struct.new(:username, :email, :password) 348 | 349 | MyContract = Dry::Schema.Params do 350 | config.messages.load_paths << "test/fixtures/dry_error_messages.yml" 351 | 352 | required(:password).filled(min_size?: 6) 353 | end 354 | 355 | class Session2Form < TestForm 356 | property :username 357 | property :email 358 | property :password 359 | 360 | validation contract: MyContract do 361 | params do 362 | required(:username).filled 363 | required(:email).filled 364 | end 365 | 366 | rule(:email) do 367 | key.failure(:good_musical_taste?) unless value.is_a? String 368 | end 369 | end 370 | end 371 | 372 | let(:form) { Session2Form.new(Session2.new) } 373 | 374 | # valid. 375 | it do 376 | skip "waiting dry-v to add this as feature https://github.com/dry-rb/dry-schema/issues/33" 377 | assert form.validate(username: "Helloween", email: "yep", password: "extrasafe") 378 | assert_equal form.errors.messages.inspect, "{}" 379 | end 380 | 381 | # invalid. 382 | it do 383 | skip "waiting dry-v to add this as feature https://github.com/dry-rb/dry-schema/issues/33" 384 | assert_equal form.validate({}), false 385 | assert_equal form.errors.messages, password: ["must be filled", "size cannot be less than 6"], username: ["must be filled"], email: ["must be filled", "you're a bad person"] 386 | end 387 | 388 | it do 389 | skip "waiting dry-v to add this as feature https://github.com/dry-rb/dry-schema/issues/33" 390 | assert_equal form.validate(email: 1), false 391 | assert_equal form.errors.messages.inspect, "{:password=>[\"must be filled\", \"size cannot be less than 6\"], :username=>[\"must be filled\"], :email=>[\"you're a bad person\"]}" 392 | end 393 | end 394 | 395 | describe "MIXED nested validations" do 396 | class AlbumForm < TestForm 397 | property :title 398 | 399 | property :hit do 400 | property :title 401 | 402 | validation do 403 | params { required(:title).filled } 404 | end 405 | end 406 | 407 | collection :songs do 408 | property :title 409 | 410 | validation do 411 | params { required(:title).filled } 412 | end 413 | end 414 | 415 | # we test this one by running an each / schema dry-v check on the main block 416 | collection :producers do 417 | property :name 418 | end 419 | 420 | property :band do 421 | property :name 422 | property :label do 423 | property :location 424 | end 425 | end 426 | 427 | validation do 428 | config.messages.load_paths << "test/fixtures/dry_error_messages.yml" 429 | params do 430 | required(:title).filled 431 | required(:band).hash do 432 | required(:name).filled 433 | required(:label).hash do 434 | required(:location).filled 435 | end 436 | end 437 | 438 | required(:producers).each do 439 | hash { required(:name).filled } 440 | end 441 | end 442 | 443 | rule(:title) do 444 | key.failure(:good_musical_taste?) unless value != "Nickelback" 445 | end 446 | end 447 | end 448 | 449 | let(:album) do 450 | OpenStruct.new( 451 | hit: OpenStruct.new, 452 | songs: [OpenStruct.new, OpenStruct.new], 453 | band: Struct.new(:name, :label).new("", OpenStruct.new), 454 | producers: [OpenStruct.new, OpenStruct.new, OpenStruct.new], 455 | ) 456 | end 457 | 458 | let(:form) { AlbumForm.new(album) } 459 | 460 | it "maps errors to form objects correctly" do 461 | result = form.validate( 462 | "title" => "Nickelback", 463 | "songs" => [{"title" => ""}, {"title" => ""}], 464 | "band" => {"size" => "", "label" => {"location" => ""}}, 465 | "producers" => [{"name" => ""}, {"name" => "something lovely"}] 466 | ) 467 | 468 | assert_equal result, false 469 | # from nested validation 470 | assert_equal form.errors.messages, title: ["you're a bad person"], "hit.title": ["must be filled"], "songs.title": ["must be filled"], "producers.name": ["must be filled"], "band.name": ["must be filled"], "band.label.location": ["must be filled"] 471 | 472 | # songs have their own validation. 473 | assert_equal form.songs[0].errors.messages, title: ["must be filled"] 474 | # hit got its own validation group. 475 | assert_equal form.hit.errors.messages, title: ["must be filled"] 476 | 477 | assert_equal form.band.label.errors.messages, location: ["must be filled"] 478 | assert_equal form.band.errors.messages, name: ["must be filled"], "label.location": ["must be filled"] 479 | assert_equal form.producers[0].errors.messages, name: ["must be filled"] 480 | 481 | # TODO: use the same form structure as the top one and do the same test against messages, errors and hints. 482 | assert_equal form.producers[0].to_result.errors, name: ["must be filled"] 483 | assert_equal form.producers[0].to_result.messages, name: ["must be filled"] 484 | assert_equal form.producers[0].to_result.hints, {} 485 | end 486 | 487 | # FIXME: fix the "must be filled error" 488 | 489 | it "renders full messages correctly" do 490 | result = form.validate( 491 | "title" => "", 492 | "songs" => [{"title" => ""}, {"title" => ""}], 493 | "band" => {"size" => "", "label" => {"name" => ""}}, 494 | "producers" => [{"name" => ""}, {"name" => ""}, {"name" => "something lovely"}] 495 | ) 496 | 497 | assert_equal result, false 498 | assert_equal form.band.errors.full_messages, ["Name must be filled", "Label Location must be filled"] 499 | assert_equal form.band.label.errors.full_messages, ["Location must be filled"] 500 | assert_equal form.producers.first.errors.full_messages, ["Name must be filled"] 501 | assert_equal form.errors.full_messages, ["Title must be filled", "Hit Title must be filled", "Songs Title must be filled", "Producers Name must be filled", "Band Name must be filled", "Band Label Location must be filled"] 502 | end 503 | 504 | describe "only 1 nested validation" do 505 | class AlbumFormWith1NestedVal < TestForm 506 | property :title 507 | property :band do 508 | property :name 509 | property :label do 510 | property :location 511 | end 512 | end 513 | 514 | validation do 515 | config.messages.load_paths << "test/fixtures/dry_error_messages.yml" 516 | 517 | params do 518 | required(:title).filled 519 | 520 | required(:band).schema do 521 | required(:name).filled 522 | required(:label).schema do 523 | required(:location).filled 524 | end 525 | end 526 | end 527 | end 528 | end 529 | 530 | let(:form) { AlbumFormWith1NestedVal.new(album) } 531 | 532 | it "allows to access dry's result semantics per nested form" do 533 | form.validate( 534 | "title" => "", 535 | "songs" => [{"title" => ""}, {"title" => ""}], 536 | "band" => {"size" => "", "label" => {"name" => ""}}, 537 | "producers" => [{"name" => ""}, {"name" => ""}, {"name" => "something lovely"}] 538 | ) 539 | 540 | assert_equal form.to_result.errors, title: ["must be filled"] 541 | assert_equal form.band.to_result.errors, name: ["must be filled"] 542 | assert_equal form.band.label.to_result.errors, location: ["must be filled"] 543 | 544 | # with locale: "de" 545 | assert_equal form.to_result.errors(locale: :de), title: ["muss abgefüllt sein"] 546 | assert_equal form.band.to_result.errors(locale: :de), name: ["muss abgefüllt sein"] 547 | assert_equal form.band.label.to_result.errors(locale: :de), location: ["muss abgefüllt sein"] 548 | end 549 | end 550 | end 551 | 552 | # describe "same-named group" do 553 | # class OverwritingForm < TestForm 554 | # include Reform::Form::Dry::Validations 555 | 556 | # property :username 557 | # property :email 558 | 559 | # validation :email do # FIX ME: is this working for other validator or just bugging here? 560 | # key(:email, &:filled?) # it's not considered, overitten 561 | # end 562 | 563 | # validation :email do # just another group. 564 | # key(:username, &:filled?) 565 | # end 566 | # end 567 | 568 | # let(:form) { OverwritingForm.new(Session.new) } 569 | 570 | # # valid. 571 | # it do 572 | # form.validate({username: "Helloween"}).must_equal true 573 | # end 574 | 575 | # # invalid. 576 | # it "whoo" do 577 | # form.validate({}).must_equal false 578 | # form.errors.messages.inspect.must_equal "{:username=>[\"username can't be blank\"]}" 579 | # end 580 | # end 581 | 582 | describe "inherit: true in same group" do 583 | class InheritSameGroupForm < TestForm 584 | property :username 585 | property :email 586 | property :full_name, virtual: true 587 | 588 | validation name: :username do 589 | params do 590 | required(:username).filled 591 | required(:full_name).filled 592 | end 593 | end 594 | 595 | validation name: :username, inherit: true do # extends the above. 596 | params do 597 | optional(:username).maybe(:string) 598 | required(:email).filled 599 | end 600 | end 601 | end 602 | 603 | let(:form) { InheritSameGroupForm.new(Session.new) } 604 | 605 | # valid. 606 | it do 607 | skip "waiting dry-v to add this as feature https://github.com/dry-rb/dry-schema/issues/33" 608 | assert form.validate(email: 9) 609 | end 610 | 611 | # invalid. 612 | it do 613 | skip "waiting dry-v to add this as feature https://github.com/dry-rb/dry-schema/issues/33" 614 | assert_equal form.validate({}), false 615 | assert_equal form.errors.messages, email: ["must be filled"], full_name: ["must be filled"] 616 | end 617 | end 618 | 619 | describe "if: with lambda" do 620 | class IfWithLambdaForm < TestForm 621 | property :username 622 | property :email 623 | property :password 624 | 625 | validation name: :email do 626 | params { required(:email).filled } 627 | end 628 | 629 | # run this is :email group is true. 630 | validation name: :after_email, if: ->(results) { results[:email].success? } do # extends the above. 631 | params { required(:username).filled } 632 | end 633 | 634 | # block gets evaled in form instance context. 635 | validation name: :password, if: ->(results) { email == "john@trb.org" } do 636 | params { required(:password).filled } 637 | end 638 | end 639 | 640 | let(:form) { IfWithLambdaForm.new(Session.new) } 641 | 642 | # valid. 643 | it do 644 | assert form.validate(username: "Strung Out", email: 9) 645 | end 646 | 647 | # invalid. 648 | it do 649 | assert_equal form.validate(email: 9), false 650 | assert_equal form.errors.messages.inspect, "{:username=>[\"must be filled\"]}" 651 | end 652 | end 653 | 654 | class NestedSchemaValidationTest < MiniTest::Spec 655 | AddressSchema = Dry::Schema.Params do 656 | required(:company).filled(:int?) 657 | end 658 | 659 | class OrderForm < TestForm 660 | property :delivery_address do 661 | property :company 662 | end 663 | 664 | validation do 665 | params { required(:delivery_address).schema(AddressSchema) } 666 | end 667 | end 668 | 669 | let(:company) { Struct.new(:company) } 670 | let(:order) { Struct.new(:delivery_address) } 671 | let(:form) { OrderForm.new(order.new(company.new)) } 672 | 673 | it "has company error" do 674 | assert_equal form.validate(delivery_address: {company: "not int"}), false 675 | assert_equal form.errors.messages, :"delivery_address.company" => ["must be an integer"] 676 | end 677 | end 678 | 679 | class NestedSchemaValidationWithFormTest < MiniTest::Spec 680 | class CompanyForm < TestForm 681 | property :company 682 | 683 | validation do 684 | params { required(:company).filled(:int?) } 685 | end 686 | end 687 | 688 | class OrderFormWithForm < TestForm 689 | property :delivery_address, form: CompanyForm 690 | end 691 | 692 | let(:company) { Struct.new(:company) } 693 | let(:order) { Struct.new(:delivery_address) } 694 | let(:form) { OrderFormWithForm.new(order.new(company.new)) } 695 | 696 | it "has company error" do 697 | assert_equal form.validate(delivery_address: {company: "not int"}), false 698 | assert_equal form.errors.messages, :"delivery_address.company" => ["must be an integer"] 699 | end 700 | end 701 | 702 | class CollectionPropertyWithCustomRuleTest < MiniTest::Spec 703 | Artist = Struct.new(:first_name, :last_name) 704 | Song = Struct.new(:title, :enabled) 705 | Album = Struct.new(:title, :songs, :artist) 706 | 707 | class AlbumForm < TestForm 708 | property :title 709 | 710 | collection :songs, virtual: true, populate_if_empty: Song do 711 | property :title 712 | property :enabled 713 | 714 | validation do 715 | params { required(:title).filled } 716 | end 717 | end 718 | 719 | property :artist, populate_if_empty: Artist do 720 | property :first_name 721 | property :last_name 722 | end 723 | 724 | validation do 725 | config.messages.load_paths << "test/fixtures/dry_error_messages.yml" 726 | 727 | params do 728 | required(:songs).filled 729 | required(:artist).filled 730 | end 731 | 732 | rule(:songs) do 733 | key.failure(:a_song?) unless value.any? { |el| el && el[:enabled] } 734 | end 735 | 736 | rule(:artist) do 737 | key.failure(:with_last_name?) unless value[:last_name] 738 | end 739 | end 740 | end 741 | 742 | it "validates fails and shows the correct errors" do 743 | form = AlbumForm.new(Album.new(nil, [], nil)) 744 | assert_equal form.validate( 745 | "songs" => [ 746 | {"title" => "One", "enabled" => false}, 747 | {"title" => nil, "enabled" => false}, 748 | {"title" => "Three", "enabled" => false} 749 | ], 750 | "artist" => {"last_name" => nil} 751 | ), false 752 | assert_equal form.songs.size, 3 753 | 754 | assert_equal form.errors.messages, { 755 | :songs => ["must have at least one enabled song"], 756 | :artist => ["must have last name"], 757 | :"songs.title" => ["must be filled"] 758 | } 759 | end 760 | end 761 | 762 | class DryVWithSchemaAndParams < MiniTest::Spec 763 | Foo = Struct.new(:age) 764 | 765 | class ParamsForm < TestForm 766 | property :age 767 | 768 | validation do 769 | params { required(:age).value(:integer) } 770 | 771 | rule(:age) { key.failure("value exceeded") if value > 999 } 772 | end 773 | end 774 | 775 | class SchemaForm < TestForm 776 | property :age 777 | 778 | validation do 779 | schema { required(:age).value(:integer) } 780 | 781 | rule(:age) { key.failure("value exceeded") if value > 999 } 782 | end 783 | end 784 | 785 | it "using params" do 786 | model = Foo.new 787 | form = ParamsForm.new(model) 788 | assert form.validate(age: "99") 789 | form.sync 790 | assert_equal model.age, "99" 791 | 792 | form = ParamsForm.new(Foo.new) 793 | assert_equal form.validate(age: "1000"), false 794 | assert_equal form.errors.messages, age: ["value exceeded"] 795 | end 796 | 797 | it "using schema" do 798 | model = Foo.new 799 | form = SchemaForm.new(model) 800 | assert_equal form.validate(age: "99"), false 801 | assert form.validate(age: 99) 802 | form.sync 803 | assert_equal model.age, 99 804 | 805 | form = SchemaForm.new(Foo.new) 806 | assert_equal form.validate(age: 1000), false 807 | assert_equal form.errors.messages, age: ["value exceeded"] 808 | end 809 | end 810 | 811 | # Currenty dry-v don't support that option, it doesn't make sense 812 | # I've talked to @solnic and he plans to add a "hint" feature to show 813 | # more errors messages than only those that have failed. 814 | # 815 | # describe "multiple errors for property" do 816 | # class MultipleErrorsForPropertyForm < TestForm 817 | # include Reform::Form::Dry::Validations 818 | 819 | # property :username 820 | 821 | # validation :default do 822 | # key(:username) do |username| 823 | # username.filled? | (username.min_size?(2) & username.max_size?(3)) 824 | # end 825 | # end 826 | # end 827 | 828 | # let(:form) { MultipleErrorsForPropertyForm.new(Session.new) } 829 | 830 | # # valid. 831 | # it do 832 | # form.validate({username: ""}).must_equal false 833 | # form.errors.messages.inspect.must_equal "{:username=>[\"username must be filled\", \"username is not proper size\"]}" 834 | # end 835 | # end 836 | end 837 | -------------------------------------------------------------------------------- /test/validation/result_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ErrorsResultTest < Minitest::Spec 4 | MyResult = Struct.new(:success?, :errors) do 5 | def failure?; !success? end 6 | end 7 | 8 | # TODO: errors(args) not tested. 9 | 10 | describe "Contract::Result#success?" do 11 | let(:failed) { MyResult.new(false) } 12 | let(:succeeded) { MyResult.new(true) } 13 | 14 | it { assert_equal Reform::Contract::Result.new([failed, failed]).success?, false } 15 | it { assert_equal Reform::Contract::Result.new([succeeded, failed]).success?, false } 16 | it { assert_equal Reform::Contract::Result.new([failed, succeeded]).success?, false } 17 | it { assert Reform::Contract::Result.new([succeeded, succeeded]).success? } 18 | end 19 | 20 | describe "Contract::Result#errors" do 21 | let(:results) do 22 | [ 23 | MyResult.new(false, {length: ["no Int"]}), 24 | MyResult.new(false, {title: ["must be filled"], nested: {something: []}}), 25 | MyResult.new(false, {title: ["must be filled"], nested: {something: []}}), 26 | MyResult.new(false, {title: ["something more"], nested: {something: []}}) 27 | ] 28 | end 29 | 30 | it { assert_equal Reform::Contract::Result.new(results).errors, {title: ["must be filled", "something more"], length: ["no Int"]} } 31 | end 32 | 33 | describe "Result::Pointer" do 34 | let(:errors) do # dry result #errors format. 35 | { 36 | title: ["ignore"], 37 | artist: {age: ["too old"], 38 | bands: { 39 | 0 => {name: "too new school"}, 40 | 1 => {name: "too boring"}, 41 | } 42 | } 43 | } 44 | end 45 | 46 | let(:top) { Reform::Contract::Result::Pointer.new(MyResult.new(false, errors), []) } 47 | it { assert_equal top.success?, false } 48 | it { assert_equal top.errors, errors } 49 | 50 | let(:artist) { Reform::Contract::Result::Pointer.new(MyResult.new(false, errors), [:artist]) } 51 | it { assert_equal artist.success?, false } 52 | it { assert_equal artist.errors,({age: ["too old"], bands: {0 => {name: "too new school"}, 1 => {name: "too boring"}}}) } 53 | 54 | let(:band) { Reform::Contract::Result::Pointer.new(MyResult.new(false, errors), [:artist, :bands, 1]) } 55 | it { assert_equal band.success?, false } 56 | it { assert_equal band.errors,({name: "too boring"}) } 57 | 58 | describe "advance" do 59 | let(:advanced) { artist.advance(:bands, 1) } 60 | 61 | it { assert_equal advanced.success?, false } 62 | it { assert_equal advanced.errors,({name: "too boring"}) } 63 | 64 | it { assert_nil artist.advance(%i[absolute nonsense]) } 65 | end 66 | end 67 | end 68 | 69 | # validation group: 70 | 71 | # form.errors/messages/hint(*args) ==> {:title: [..]} 72 | # @call_result.errors/messages/hint(*args) } 73 | 74 | # # result = Result(original_result => [:band, :label], my_local_result => [] ) 75 | # # result.messages(locale: :en) merges original_result and my_local_result 76 | 77 | # form.errors => Result(fetch tree of all nested forms.messages(*args)) 78 | -------------------------------------------------------------------------------- /test/validation_library_provided_test.rb: -------------------------------------------------------------------------------- 1 | require "reform" 2 | require "minitest/autorun" 3 | 4 | class ValidationLibraryProvidedTest < MiniTest::Spec 5 | it "no validation library loaded" do 6 | assert_raises Reform::Validation::NoValidationLibraryError do 7 | class PersonForm < Reform::Form 8 | property :name 9 | 10 | validation do 11 | required(:name).maybe(:str?) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/virtual_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class VirtualTest < MiniTest::Spec 4 | class CreditCardForm < TestForm 5 | property :credit_card_number, virtual: true # no read, no write, it's virtual. 6 | collection :transactions, virtual: true, populate_if_empty: OpenStruct do 7 | property :id 8 | end 9 | end 10 | 11 | let(:form) { CreditCardForm.new(Object.new) } 12 | 13 | it { 14 | form.validate(credit_card_number: "123", transactions: [id: 1]) 15 | 16 | assert_equal form.credit_card_number, "123" # this is still readable in the UI. 17 | assert_equal form.transactions.first.id, 1 # this is still readable in the UI. 18 | 19 | form.sync 20 | 21 | hash = {} 22 | form.save do |nested| 23 | hash = nested 24 | end 25 | 26 | assert_equal hash, "credit_card_number" => "123", "transactions" => ["id" => 1] 27 | } 28 | end 29 | 30 | class VirtualAndDefaultTest < MiniTest::Spec 31 | class CreditCardForm < TestForm 32 | property :credit_card_number, virtual: true, default: "123" # no read, no write, it's virtual. 33 | collection :transactions, virtual: true, populate_if_empty: OpenStruct, default: [OpenStruct.new(id: 2)] do 34 | property :id 35 | end 36 | end 37 | 38 | def hash(form) 39 | hash = {} 40 | form.save do |nested| 41 | hash = nested 42 | end 43 | hash 44 | end 45 | 46 | let(:form) { CreditCardForm.new(Object.new) } 47 | 48 | it { 49 | form = CreditCardForm.new(Object.new) 50 | form.validate({}) 51 | 52 | assert_equal hash(form), "credit_card_number" => "123", "transactions" => ["id" => 2] 53 | 54 | form = CreditCardForm.new(Object.new) 55 | form.validate(credit_card_number: "123", transactions: [id: 1]) 56 | 57 | assert_equal form.credit_card_number, "123" # this is still readable in the UI. 58 | assert_equal form.transactions.first.id, 1 # this is still readable in the UI. 59 | 60 | form.sync 61 | 62 | assert_equal hash(form), "credit_card_number" => "123", "transactions" => ["id" => 1] 63 | } 64 | end 65 | -------------------------------------------------------------------------------- /test/writeable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class WriteableTest < MiniTest::Spec 4 | Location = Struct.new(:country) 5 | 6 | class LocationForm < TestForm 7 | property :country, writeable: false 8 | end 9 | 10 | let(:loc) { Location.new("Australia") } 11 | let(:form) { LocationForm.new(loc) } 12 | 13 | it do 14 | assert_equal form.country, "Australia" 15 | 16 | form.validate("country" => "Germany") # this usually won't change when submitting. 17 | assert_equal form.country, "Germany" 18 | 19 | form.sync 20 | assert_equal loc.country, "Australia" # the writer wasn't called. 21 | 22 | hash = {} 23 | form.save do |nested| 24 | hash = nested 25 | end 26 | 27 | assert_equal hash, "country" => "Germany" 28 | end 29 | end 30 | 31 | # writable option is alias of writeable option. 32 | class WritableTest < MiniTest::Spec 33 | Location = Struct.new(:country) 34 | 35 | class LocationForm < TestForm 36 | property :country, writable: false 37 | end 38 | 39 | let(:loc) { Location.new("Australia") } 40 | let(:form) { LocationForm.new(loc) } 41 | 42 | it do 43 | assert_equal form.country, "Australia" 44 | 45 | form.validate("country" => "Germany") # this usually won't change when submitting. 46 | assert_equal form.country, "Germany" 47 | 48 | form.sync 49 | assert_equal loc.country, "Australia" # the writer wasn't called. 50 | 51 | hash = {} 52 | form.save do |nested| 53 | hash = nested 54 | end 55 | 56 | assert_equal hash, "country" => "Germany" 57 | end 58 | end 59 | --------------------------------------------------------------------------------