├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .projections.json ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── mini_form.rb └── mini_form │ ├── errors.rb │ ├── model.rb │ ├── nested_validator.rb │ └── version.rb ├── mini_form.gemspec └── spec ├── mini_form └── nested_validator_spec.rb ├── mini_form_spec.rb └── spec_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | ruby-version: ['2.6', '2.7', '3.0', '3.1'] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Ruby ${{ matrix.ruby-version }} 21 | uses: ruby/setup-ruby@0a29871fe2b0200a17a4497bae54fe5df0d973aa # v1.115.3 22 | with: 23 | ruby-version: ${{ matrix.ruby-version }} 24 | 25 | - name: Install dependencies 26 | run: bundle install 27 | 28 | - name: Run linters 29 | run: bundle exec rubocop 30 | 31 | - name: Run tests 32 | run: bundle exec rspec spec 33 | 34 | -------------------------------------------------------------------------------- /.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 | .vimrc.local 19 | -------------------------------------------------------------------------------- /.projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib/*.rb": { 3 | "alternate": "spec/{}_spec.rb" 4 | }, 5 | "spec/*_spec.rb": { 6 | "alternate": "lib/{}.rb" 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format=documentation 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | 3 | AllCops: 4 | Exclude: 5 | - mini_form.gemspec 6 | 7 | SuggestExtensions: false 8 | NewCops: disable 9 | 10 | # Disables "Module definition is too long" 11 | Metrics/ModuleLength: 12 | Enabled: false 13 | 14 | # Disables "Line is too long" 15 | Layout/LineLength: 16 | Enabled: false 17 | 18 | # Disables "Method is too long" 19 | Metrics/MethodLength: 20 | Enabled: false 21 | 22 | # Disables "Assignment Branch Condition size for included is too high" 23 | Metrics/AbcSize: 24 | Enabled: false 25 | 26 | # Disables "Block has too many lines" 27 | Metrics/BlockLength: 28 | Enabled: false 29 | 30 | # Disables "Missing top-level class documentation comment" 31 | Style/Documentation: 32 | Enabled: false 33 | 34 | # Disables "Use each_with_object instead of inject" 35 | Style/EachWithObject: 36 | Enabled: false 37 | 38 | # Disables "Prefer reduce over inject." 39 | Style/CollectionMethods: 40 | Enabled: false 41 | 42 | # Disables "%i-literals should be delimited by [ and ]." 43 | Style/PercentLiteralDelimiters: 44 | Enabled: false 45 | 46 | # Disables "Example has too many expectations" 47 | RSpec/MultipleExpectations: 48 | Enabled: false 49 | 50 | # Disables "Example has too many lines" 51 | RSpec/ExampleLength: 52 | Enabled: false 53 | 54 | Style/HashSyntax: 55 | Enabled: false 56 | 57 | Lint/MissingSuper: 58 | Enabled: false 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 0.2.5 4 | 5 | * Added `main_model` (@rstankov) 6 | 7 | ## Version 0.2.4 8 | 9 | * Rails 6+ support (@emilov) 10 | 11 | ## Version 0.2.3 12 | 13 | * Fix handling delegated attributes prefixes in `attribute_names` (@rstankov) 14 | 15 | ## Version 0.2.2 16 | 17 | * Bump activemodel requirements (@vestimir) 18 | 19 | ## Version 0.2.1 20 | 21 | * Fix typo in `assignment` callbaks (@ayrton) 22 | * Alias `assignment` to `assigment` for backwards compatibility (@ayrton) 23 | 24 | ## Version 0.2.0 25 | 26 | * Don't expose model name on `model`. _(security fix)_ 27 | 28 | * Included `ActiveModel::Validations::Callbacks` to `MiniForm::Model` 29 | 30 | * Added read option to `model`: 31 | 32 | ```ruby 33 | class EditProfile 34 | include MiniForm::Model 35 | 36 | model :user, attributes: %i(email name), read:%(id) 37 | end 38 | 39 | profile = EditProfile.new(user: user) 40 | profile.id 41 | profile.id = 1 # raises NoMethodError 42 | ``` 43 | 44 | 45 | * MiniForm::Model can be inherited 46 | * Added `assigment` callbacks, called when attributes are assigned 47 | * Added `assign_attributes` alias to `attributes=` 48 | * Exposed `errors` on MiniForm::InvalidForm 49 | * Added descriptive message MiniForm::InvalidForm 50 | * Made `.attribute_names` public 51 | 52 | ## Version 0.1.0 53 | 54 | * Initial release 55 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2015 Radoslav Stankov 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 | [![Gem Version](https://badge.fury.io/rb/mini_form.svg)](http://badge.fury.io/rb/mini_form) 2 | [![Code Climate](https://codeclimate.com/github/RStankov/MiniForm.svg)](https://codeclimate.com/github/RStankov/MiniForm) 3 | [![Code coverage](https://coveralls.io/repos/RStankov/MiniForm/badge.svg?branch=master)](https://coveralls.io/r/RStankov/MiniForm) 4 | 5 | # MiniForm 6 | 7 | Helpers for dealing with form objects and nested forms. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'mini_form' 15 | ``` 16 | 17 | And then execute: 18 | 19 | $ bundle 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install mini_form 24 | 25 | ## Usage 26 | 27 | ```ruby 28 | class ProductForm 29 | include MiniForm::Model 30 | 31 | attributes :id, :name, :price, :description 32 | 33 | validates :name, :price, :description, presence: true 34 | 35 | # called after successful validations in update 36 | def perform 37 | @id = ExternalService.create(attributes) 38 | end 39 | end 40 | ``` 41 | 42 | ```ruby 43 | class ProductsController < ApplicationController 44 | def create 45 | @product = ProductForm.new 46 | 47 | if @product.update(product_params) 48 | redirect_to product_path(@product.id) 49 | else 50 | render :edit 51 | end 52 | end 53 | 54 | private 55 | 56 | def product_params 57 | params.require(:product).permit(:name, :price, :description) 58 | end 59 | end 60 | ``` 61 | 62 | ### Delegated attributes 63 | 64 | Attributes can be delegated to a sub object. 65 | 66 | ```ruby 67 | class SignUpForm 68 | include MiniForm::Model 69 | 70 | attr_reader :account, :user 71 | 72 | attributes :name, :email, delegate: :user 73 | attributes :company_name, :plan, delegate: :account 74 | 75 | validates :name, :email, :company_name, :plan, presence: true 76 | 77 | def initialize 78 | @account = Account.new 79 | @user = User.new account: @account 80 | end 81 | 82 | def perform 83 | user.save! 84 | account.save! 85 | end 86 | end 87 | ``` 88 | 89 | ```ruby 90 | form = SignUpForm.new 91 | form.name = 'name' # => form.user.name = 'name' 92 | form.name # => form.user.name 93 | form.plan = 'free' # => form.account.plan = 'free' 94 | form.plan # => form.account.plan 95 | ``` 96 | 97 | ### Nested validator 98 | 99 | `mini_form/nested` validator runs validations on the given model and copies errors to the form object. 100 | 101 | ```ruby 102 | class SignUpForm 103 | include MiniForm::Model 104 | 105 | attr_reader :account, :user 106 | 107 | attributes :name, :email, delegate: :user 108 | attributes :company_name, :plan, delegate: :account 109 | 110 | validates :account, :user, 'mini_form/nested' => true 111 | 112 | def initialize 113 | @account = Account.new 114 | @user = User.new account: @account 115 | end 116 | 117 | def perform 118 | account.save! 119 | user.save! 120 | end 121 | end 122 | ``` 123 | 124 | ### Nested models 125 | 126 | Combines delegated attributes and nested validation into a single call. 127 | 128 | ```ruby 129 | class SignUpForm 130 | include MiniForm::Model 131 | 132 | model :user, attributes: %i(name email) 133 | model :account, attributes: %i(company_name plan) 134 | 135 | def initialize 136 | @account = Account.new 137 | @user = User.new account: @account 138 | end 139 | 140 | def perform 141 | account.save! 142 | user.save! 143 | end 144 | end 145 | ``` 146 | 147 | ### Auto saving nested models 148 | 149 | Most of the time `perform` is just calling `save!`. We can avoid this by using `model`'s `save` option. 150 | 151 | ```ruby 152 | class SignUpForm 153 | include MiniForm::Model 154 | 155 | model :user, attributes: %i(name email), save: true 156 | model :account, attributes: %i(company_name plan), save: true 157 | 158 | def initialize 159 | @account = Account.new 160 | @user = User.new account: @account 161 | end 162 | end 163 | ``` 164 | 165 | ### Before/after callbacks 166 | 167 | ```ruby 168 | class SignUpForm 169 | include MiniForm::Model 170 | 171 | # ... code 172 | 173 | before_update :run_before_update 174 | after_update :run_after_update 175 | 176 | private 177 | 178 | def run_before_update 179 | # ... 180 | end 181 | 182 | def run_after_update 183 | # ... 184 | end 185 | 186 | # alternatively you can overwrite "before_update" 187 | def before_update 188 | end 189 | 190 | # alternatively you can overwrite "after_update" 191 | def after_update 192 | end 193 | end 194 | ``` 195 | 196 | ### Using in forms 197 | 198 | Using `main_model` will delegate `id`, `to_param`, `persisted?` and `new_record?` to the model. Allowing you to use it in forms. 199 | 200 | ```ruby 201 | class SignUpForm 202 | include MiniForm::Model 203 | 204 | main_model :user 205 | 206 | def initialize 207 | @user = User.new(account: @account) 208 | end 209 | end 210 | ``` 211 | 212 | ```eruby 213 | <% form_for SignUpForm.new %> 214 | ``` 215 | 216 | ### Delegating model attributes 217 | 218 | ```ruby 219 | class SignUpForm 220 | include MiniForm::Model 221 | 222 | model :user, attributes: %i(name email), read: %i(id) 223 | 224 | def initialize 225 | @user = User.new(account: @account) 226 | end 227 | end 228 | ``` 229 | 230 | ``` 231 | form = SignUpForm.new 232 | form.update! form_params 233 | form.id # => delegates to `user.id` 234 | form.id = 42 # => raises `NoMethodError` 235 | ``` 236 | 237 | ### Methods 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 |
MethodDescription
.modelDefines a sub object for the form
.attributesDefines an attribute, it can delegate to sub object
.attribute_namesReturns list of attribute names
#initializeMeant to be overwritten. By defaults calls `attributes=`
#attributes=Sets values of all attributes
#attributesReturns all attributes of the form
#updateSets attributes, calls validations, saves models and `perform`
#update!Calls `update`. If validation fails, it raises an error
#performMeant to be overwritten. Doesn't do anything by default
#before_updateMeant to be overwritten.
#after_updateMeant to be overwritten.
#before_assignmentMeant to be overwritten.
#after_assignmentMeant to be overwritten.
#transactionIf ActiveRecord is available, wraps `perform` in transaction.
301 | 302 | ## Contributing 303 | 304 | 1. Fork it 305 | 2. Create your feature branch (`git checkout -b my-new-feature`) 306 | 3. Commit your changes (`git commit -am 'Add some feature'`) 307 | 4. Push to the branch (`git push origin my-new-feature`) 308 | 5. Run the tests (`rake`) 309 | 6. Create new Pull Request 310 | 311 | ## License 312 | 313 | **[MIT License](https://github.com/RStankov/MiniForm/blob/master/LICENSE.txt)** 314 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'rubocop/rake_task' 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new(:rubocop) 9 | 10 | task default: %i(rubocop spec) 11 | -------------------------------------------------------------------------------- /lib/mini_form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'mini_form/version' 4 | require 'mini_form/model' 5 | require 'mini_form/errors' 6 | require 'mini_form/nested_validator' 7 | 8 | module MiniForm 9 | end 10 | -------------------------------------------------------------------------------- /lib/mini_form/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MiniForm 4 | class InvalidForm < StandardError 5 | attr_reader :errors 6 | 7 | def initialize(object) 8 | @errors = object.errors 9 | 10 | arr_obj = errors.respond_to?(:attribute_names) ? errors.attribute_names : errors.keys 11 | super "Form validation failed for: #{arr_obj.join(', ')}" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/mini_form/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/all' 4 | require 'active_model' 5 | 6 | module MiniForm 7 | module Model 8 | def self.included(base) 9 | base.class_eval do 10 | include ActiveModel::Validations 11 | include ActiveModel::Validations::Callbacks 12 | include ActiveModel::Conversion 13 | 14 | extend ActiveModel::Naming 15 | extend ActiveModel::Callbacks 16 | 17 | extend ClassMethods 18 | 19 | define_model_callbacks :update 20 | define_model_callbacks :assignment 21 | 22 | # For backwards compatibility purpose 23 | define_model_callbacks :assigment 24 | 25 | before_update :before_update 26 | after_update :after_update 27 | 28 | before_assignment :before_assignment 29 | after_assignment :after_assignment 30 | 31 | # For backwards compatibility purpose 32 | before_assigment :before_assigment 33 | after_assigment :after_assigment 34 | end 35 | end 36 | 37 | def initialize(attributes = {}) 38 | self.attributes = attributes 39 | end 40 | 41 | def persisted? 42 | false 43 | end 44 | 45 | def attributes=(attributes) 46 | run_callbacks :assignment do 47 | run_callbacks :assigment do 48 | attributes.slice(*self.class.attribute_names).each do |name, value| 49 | public_send "#{name}=", value 50 | end 51 | end 52 | end 53 | end 54 | 55 | alias assign_attributes attributes= 56 | 57 | def attributes 58 | Hash[self.class.attribute_names.map { |name| [name, public_send(name)] }] 59 | end 60 | 61 | def update(attributes = {}) 62 | self.attributes = attributes unless attributes.empty? 63 | 64 | return false unless valid? 65 | 66 | run_callbacks :update do 67 | transaction do 68 | save_models 69 | perform 70 | end 71 | end 72 | 73 | true 74 | end 75 | 76 | def update!(attributes = {}) 77 | raise InvalidForm, self unless update attributes 78 | 79 | self 80 | end 81 | 82 | private 83 | 84 | def transaction(&block) 85 | if defined? ActiveRecord 86 | ActiveRecord::Base.transaction(&block) 87 | else 88 | yield 89 | end 90 | end 91 | 92 | # :api: private 93 | def save_models 94 | self.class.models_to_save.each { |model_name| public_send(model_name).save! } 95 | end 96 | 97 | def perform 98 | # noop 99 | end 100 | 101 | def before_update 102 | # noop 103 | end 104 | 105 | def after_update 106 | # noop 107 | end 108 | 109 | def before_assignment 110 | # noop 111 | end 112 | 113 | def after_assignment 114 | # noop 115 | end 116 | 117 | def before_assigment 118 | # noop 119 | end 120 | 121 | def after_assigment 122 | # noop 123 | end 124 | 125 | module ClassMethods 126 | def inherited(base) 127 | new_attribute_names = attribute_names.dup 128 | new_models_to_save = models_to_save.dup 129 | 130 | base.instance_eval do 131 | @attribute_names = new_attribute_names 132 | @models_to_save = new_models_to_save 133 | end 134 | end 135 | 136 | def attribute_names 137 | @attribute_names ||= [] 138 | end 139 | 140 | # :api: private 141 | def models_to_save 142 | @models_to_save ||= [] 143 | end 144 | 145 | def attributes(*attributes, delegate: nil, prefix: nil, allow_nil: nil) 146 | if prefix 147 | attribute_names.push(*attributes.map do |name| 148 | :"#{prefix == true ? delegate : prefix}_#{name}" 149 | end) 150 | else 151 | attribute_names.push(*attributes) 152 | end 153 | 154 | if delegate.nil? 155 | attr_accessor(*attributes) 156 | else 157 | delegate(*attributes, to: delegate, prefix: prefix, allow_nil: allow_nil) 158 | delegate(*attributes.map { |attr| "#{attr}=" }, to: delegate, prefix: prefix, allow_nil: allow_nil) 159 | end 160 | end 161 | 162 | def model(name, attributes: [], read: [], prefix: nil, allow_nil: nil, save: false) # rubocop:disable Metrics/ParameterLists 163 | attr_accessor name 164 | 165 | attributes(*attributes, delegate: name, prefix: prefix, allow_nil: allow_nil) unless attributes.empty? 166 | 167 | delegate(*read, to: name, prefix: prefix, allow_nil: nil) 168 | 169 | validates name, 'mini_form/nested' => true 170 | 171 | models_to_save << name if save 172 | end 173 | 174 | def main_model(model_name, **args) 175 | delegate :id, :persisted?, :to_param, :new_record?, to: model_name 176 | 177 | model model_name, **args 178 | end 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/mini_form/nested_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_model' 4 | 5 | module MiniForm 6 | class NestedValidator < ActiveModel::EachValidator 7 | def validate_each(record, _, relation) 8 | return if relation.valid? 9 | 10 | if record.errors.respond_to?(:merge!) 11 | # Rails 6.1+ where accessing ActiveModel::Errors as a hash has been 12 | # deprecated and the errors array is frozen. For this reason we use the new 13 | # method merge!() which appends the errors as NestedErrors to the array. "This is the way." 14 | record.errors.merge!(relation.errors) 15 | return 16 | end 17 | 18 | # Rails < 6.1 19 | relation.errors.each do |name, value| 20 | record.errors.add name, value 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/mini_form/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MiniForm 4 | VERSION = '0.2.5' 5 | end 6 | -------------------------------------------------------------------------------- /mini_form.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'mini_form/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'mini_form' 8 | spec.version = MiniForm::VERSION 9 | spec.authors = ['Radoslav Stankov'] 10 | spec.email = ['rstankov@gmail.com'] 11 | spec.description = 'Sugar around ActiveModel::Model' 12 | spec.summary = 'Easy to use form objects in Rails projects' 13 | spec.homepage = 'https://github.com/RStankov/MiniForm' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 17 | spec.executables = spec.files.grep(%r{^bin\/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(spec)\/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'activemodel', '>= 4.0' 22 | 23 | spec.add_development_dependency 'bundler', '>= 2.1.4' 24 | spec.add_development_dependency 'rake', '>= 12.3.3' 25 | spec.add_development_dependency 'rspec', '3.12.0' 26 | spec.add_development_dependency 'rspec-mocks', '3.12.0' 27 | spec.add_development_dependency 'coveralls' 28 | spec.add_development_dependency 'rubocop' 29 | spec.add_development_dependency 'rubocop-rspec' 30 | end 31 | -------------------------------------------------------------------------------- /spec/mini_form/nested_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module SpecSupport 6 | class Person 7 | include ActiveModel::Model 8 | 9 | attr_accessor :name 10 | 11 | validates :name, presence: true 12 | end 13 | 14 | class Record 15 | include ActiveModel::Validations 16 | 17 | attr_accessor :user 18 | 19 | def initialize(user) 20 | @user = user 21 | end 22 | end 23 | end 24 | 25 | module MiniForm 26 | describe NestedValidator do 27 | let(:validator) { described_class.new(attributes: [:user]) } 28 | let(:user) { SpecSupport::Person.new } 29 | let(:record) { SpecSupport::Record.new(user) } 30 | 31 | it 'copies errors from submodel to model' do 32 | validator.validate(record) 33 | 34 | expect(record.errors[:name]).not_to be_blank 35 | end 36 | 37 | it 'does not copy errors when there are not any' do 38 | user.name = 'valid name' 39 | 40 | validator.validate(record) 41 | 42 | expect(record.errors[:name]).to be_blank 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/mini_form_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module SpecSupport 6 | class User 7 | include ActiveModel::Model 8 | 9 | attr_accessor :id, :name, :age 10 | 11 | validates :name, presence: true 12 | 13 | def to_param 14 | "user-#{id}" 15 | end 16 | 17 | def persisted? 18 | id.present? 19 | end 20 | 21 | def new_record? 22 | !persisted? 23 | end 24 | end 25 | 26 | class Example 27 | include MiniForm::Model 28 | 29 | attributes :name, :price 30 | end 31 | 32 | class ExampleWithDelegate 33 | include MiniForm::Model 34 | 35 | attr_reader :user 36 | 37 | attributes :name, delegate: :user 38 | attributes :id, delegate: :user, prefix: true 39 | attributes :name, delegate: :user, prefix: 'full' 40 | 41 | def initialize(user) 42 | @user = user 43 | end 44 | end 45 | 46 | class ExampleWithModel 47 | include MiniForm::Model 48 | 49 | model :user, attributes: %i(name), read: %i(id) 50 | 51 | def initialize(user) 52 | self.user = user 53 | end 54 | end 55 | 56 | class ExampleForUpdate 57 | include MiniForm::Model 58 | 59 | attributes :name 60 | 61 | validates :name, presence: true 62 | end 63 | 64 | class ExampleForSave 65 | include MiniForm::Model 66 | 67 | model :user, attributes: %i(name), save: true 68 | 69 | def initialize(user:) 70 | self.user = user 71 | end 72 | end 73 | 74 | class ExampleFormModel 75 | include MiniForm::Model 76 | 77 | main_model :user 78 | 79 | def initialize(user:) 80 | self.user = user 81 | end 82 | end 83 | end 84 | 85 | module MiniForm 86 | describe Model do 87 | let(:user) { SpecSupport::User.new id: 1, name: 'name', age: 28 } 88 | 89 | describe 'acts as ActiveModel' do 90 | include ActiveModel::Lint::Tests 91 | 92 | before do 93 | @model = SpecSupport::Example.new 94 | end 95 | 96 | def assert(condition, message = nil) 97 | expect(condition).to be_truthy, message 98 | end 99 | 100 | def assert_kind_of(expected_kind, object, message = nil) 101 | expect(object).to be_kind_of(expected_kind), message 102 | end 103 | 104 | def assert_equal(expected_value, value, message = nil) 105 | expect(value).to eq(expected_value), message 106 | end 107 | 108 | def assert_respond_to(klass, method, message = nil) 109 | expect(klass).to respond_to(method), message 110 | end 111 | 112 | ActiveModel::Lint::Tests.public_instance_methods.map(&:to_s).grep(/^test/).each do |method| 113 | example(method.gsub('_', ' ')) { send method } 114 | end 115 | end 116 | 117 | describe 'inheritance' do 118 | it 'can be inherited' do 119 | parent_class = Class.new do 120 | include Model 121 | 122 | attributes :name 123 | end 124 | 125 | child_class = Class.new(parent_class) do 126 | attributes :age 127 | end 128 | 129 | expect(parent_class.attribute_names).to eq %i(name) 130 | expect(child_class.attribute_names).to eq %i(name age) 131 | end 132 | end 133 | 134 | describe '.attributes' do 135 | it 'generates getters' do 136 | object = SpecSupport::Example.new name: 'value' 137 | expect(object.name).to eq 'value' 138 | end 139 | 140 | it 'generates setters' do 141 | object = SpecSupport::Example.new 142 | object.name = 'value' 143 | 144 | expect(object.name).to eq 'value' 145 | end 146 | 147 | it 'can delegate getter' do 148 | object = SpecSupport::ExampleWithDelegate.new user 149 | expect(object.name).to eq user.name 150 | end 151 | 152 | it 'can delegate setter' do 153 | object = SpecSupport::ExampleWithDelegate.new user 154 | 155 | object.name = 'New Name' 156 | 157 | expect(object.name).to eq 'New Name' 158 | expect(user.name).to eq 'New Name' 159 | end 160 | end 161 | 162 | describe '.model' do 163 | it 'generates model accessors' do 164 | object = SpecSupport::ExampleWithModel.new user 165 | expect(object.user).to eq user 166 | end 167 | 168 | it 'can delegate only a reader' do 169 | object = SpecSupport::ExampleWithModel.new user 170 | 171 | expect(object).not_to respond_to :id= 172 | expect(object.id).to eq user.id 173 | end 174 | 175 | it 'can delegate model attributes' do 176 | object = SpecSupport::ExampleWithModel.new user 177 | expect(object.name).to eq user.name 178 | end 179 | 180 | it 'performs nested validation for model' do 181 | user = SpecSupport::User.new 182 | object = SpecSupport::ExampleWithModel.new user 183 | 184 | expect(object).not_to be_valid 185 | expect(object.errors[:name]).to be_present 186 | end 187 | end 188 | 189 | describe '.main_model' do 190 | it 'delegates Rails form attributes to the model' do 191 | user = SpecSupport::User.new 192 | object = SpecSupport::ExampleFormModel.new(user: user) 193 | 194 | expect(object).to have_attributes( 195 | id: user.id, 196 | to_param: user.to_param, 197 | persisted?: user.persisted?, 198 | new_record?: user.new_record? 199 | ) 200 | end 201 | end 202 | 203 | describe '.attributes_names' do 204 | it 'returns attribute names' do 205 | expect(SpecSupport::Example.attribute_names).to eq %i(name price) 206 | end 207 | 208 | it 'can handle prefixes' do 209 | expect(SpecSupport::ExampleWithDelegate.attribute_names).to include :user_id 210 | expect(SpecSupport::ExampleWithDelegate.attribute_names).to include :full_name 211 | end 212 | end 213 | 214 | describe '#initialize' do 215 | it 'can be called with no arguments' do 216 | expect { SpecSupport::Example.new }.not_to raise_error 217 | end 218 | 219 | it 'assign the passed attributes' do 220 | object = SpecSupport::Example.new price: '$5' 221 | 222 | expect(object.price).to eq '$5' 223 | end 224 | 225 | it 'ignores invalid attributes' do 226 | expect { SpecSupport::Example.new invalid: 'attribute' }.not_to raise_error 227 | end 228 | 229 | it 'handles HashWithIndifferentAccess hashes' do 230 | hash = ActiveSupport::HashWithIndifferentAccess.new 'price' => '$5' 231 | object = SpecSupport::Example.new hash 232 | 233 | expect(object.price).to eq '$5' 234 | end 235 | end 236 | 237 | describe '#attributes' do 238 | it 'returns attributes' do 239 | object = SpecSupport::Example.new name: 'iPhone', price: '$5' 240 | expect(object.attributes).to eq name: 'iPhone', price: '$5' 241 | end 242 | end 243 | 244 | ['attributes=', 'assign_attributes'].each do |method_name| 245 | describe "##{method_name}" do 246 | it 'sets attributes' do 247 | object = SpecSupport::Example.new 248 | object.public_send method_name, name: 'iPhone', price: '$5' 249 | 250 | expect(object.attributes).to eq name: 'iPhone', price: '$5' 251 | end 252 | 253 | it 'ignores not listed attributes' do 254 | object = SpecSupport::Example.new 255 | object.public_send method_name, invalid: 'value' 256 | 257 | expect(object.attributes).to eq name: nil, price: nil 258 | end 259 | end 260 | end 261 | 262 | describe '#update' do 263 | it 'updates attributes' do 264 | object = SpecSupport::ExampleForUpdate.new name: 'value' 265 | 266 | expect { object.update(name: 'new value') }.to change { object.name }.to 'new value' 267 | end 268 | 269 | it 'returns true when validations pass' do 270 | object = SpecSupport::ExampleForUpdate.new name: 'value' 271 | 272 | expect(object.update).to eq true 273 | end 274 | 275 | it 'calls "perfom" method when validation pass' do 276 | object = SpecSupport::ExampleForUpdate.new name: 'value' 277 | 278 | allow(object).to receive(:perform) 279 | 280 | object.update 281 | 282 | expect(object).to have_received(:perform) 283 | end 284 | 285 | it 'calls "save" for the model' do 286 | object = SpecSupport::ExampleForSave.new user: user 287 | 288 | allow(user).to receive(:save!) 289 | 290 | object.update 291 | 292 | expect(user).to have_received(:save!) 293 | end 294 | 295 | it 'supports update callbacks' do 296 | object = SpecSupport::ExampleForUpdate.new name: 'value' 297 | 298 | allow(object).to receive(:before_update) 299 | allow(object).to receive(:after_update) 300 | 301 | object.update 302 | 303 | expect(object).to have_received(:before_update) 304 | expect(object).to have_received(:after_update) 305 | end 306 | 307 | it 'supports legacy assig callbacks' do 308 | object = SpecSupport::ExampleForUpdate.new 309 | 310 | allow(object).to receive(:before_assigment) 311 | allow(object).to receive(:after_assigment) 312 | 313 | object.update name: 'value' 314 | 315 | expect(object).to have_received(:before_assigment) 316 | expect(object).to have_received(:after_assigment) 317 | end 318 | 319 | it 'supports assign callbacks' do 320 | object = SpecSupport::ExampleForUpdate.new 321 | 322 | allow(object).to receive(:before_assignment) 323 | allow(object).to receive(:after_assignment) 324 | 325 | object.update name: 'value' 326 | 327 | expect(object).to have_received(:before_assignment) 328 | expect(object).to have_received(:after_assignment) 329 | end 330 | 331 | it 'returns false when validations fail' do 332 | object = SpecSupport::ExampleForUpdate.new name: nil 333 | 334 | expect(object.update).to eq false 335 | end 336 | 337 | it 'does not call "perfom" method when validation fail' do 338 | object = SpecSupport::ExampleForUpdate.new name: nil 339 | 340 | allow(object).to receive(:perform) 341 | 342 | object.update 343 | 344 | expect(object).not_to have_received(:perform) 345 | end 346 | end 347 | 348 | describe '#update!' do 349 | it 'returns self' do 350 | object = SpecSupport::Example.new 351 | expect(object.update!).to eq object 352 | end 353 | 354 | it 'calls update with given arguments' do 355 | object = SpecSupport::Example.new 356 | 357 | allow(object).to receive(:update).and_return true 358 | 359 | object.update! :attributes 360 | 361 | expect(object).to have_received(:update).with(:attributes) 362 | end 363 | 364 | it 'raises error when update fails' do 365 | object = SpecSupport::Example.new 366 | 367 | allow(object).to receive(:update).and_return false 368 | 369 | expect { object.update! }.to raise_error InvalidForm 370 | end 371 | end 372 | end 373 | end 374 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | 5 | if ENV['TRAVIS'] 6 | require 'coveralls' 7 | Coveralls.wear! 8 | end 9 | 10 | require 'mini_form' 11 | 12 | RSpec.configure do |config| 13 | config.expect_with(:rspec) { |c| c.syntax = :expect } 14 | end 15 | --------------------------------------------------------------------------------