├── .rspec ├── README ├── Gemfile ├── .gitignore ├── .simplecov ├── TODO ├── lib ├── active_presenter │ ├── version.rb │ └── base.rb ├── active_presenter.rb └── tasks │ ├── doc.rake │ └── gem.rake ├── Rakefile ├── LICENSE ├── active_presenter.gemspec ├── README.rdoc └── spec ├── spec_helper.rb └── base_spec.rb /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | README.rdoc -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DStrore 2 | Gemfile.lock 3 | rdoc 4 | pkg 5 | coverage 6 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | SimpleCov.start do 2 | add_group "lib", "lib" 3 | end 4 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Document protected API 2 | Add support for namespaced models. 3 | Add support for presented collections -------------------------------------------------------------------------------- /lib/active_presenter/version.rb: -------------------------------------------------------------------------------- 1 | module ActivePresenter 2 | module VERSION 3 | MAJOR = 3 4 | MINOR = 2 5 | TINY = 2 6 | 7 | STRING = [MAJOR, MINOR, TINY].join('.') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/active_presenter.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'active_record' 3 | Dir.glob(File.dirname(__FILE__)+'/active_presenter/**/*.rb').each { |l| require l } 4 | 5 | module ActivePresenter 6 | NAME = 'active_presenter' 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rdoc/task' 3 | 4 | task :default => :test 5 | 6 | task :test do 7 | require 'simplecov' 8 | SimpleCov.start do 9 | add_filter "lib/tasks/" 10 | add_filter "spec/" 11 | end 12 | require File.dirname(__FILE__)+'/lib/active_presenter' 13 | Dir.glob(File.dirname(__FILE__)+'/lib/tasks/**/*.rake').each { |l| load l } 14 | Dir['spec/**/*_spec.rb'].each { |l| require File.join(File.dirname(__FILE__),l)} 15 | end 16 | -------------------------------------------------------------------------------- /lib/tasks/doc.rake: -------------------------------------------------------------------------------- 1 | desc 'Generate documentation for the ResourceController plugin.' 2 | Rake::RDocTask.new(:rdoc) do |rdoc| 3 | rdoc.rdoc_dir = 'rdoc' 4 | rdoc.title = 'ActivePresenter' 5 | rdoc.options << '--line-numbers' << '--inline-source' 6 | rdoc.rdoc_files.include('README') 7 | rdoc.rdoc_files.include('lib/**/*.rb') 8 | end 9 | 10 | task :upload_docs => :rdoc do 11 | puts 'Deleting previous rdoc' 12 | `ssh jamesgolick.com 'rm -Rf /home/apps/jamesgolick.com/public/active_presenter/rdoc'` 13 | 14 | puts "Uploading current rdoc" 15 | `scp -r rdoc jamesgolick.com:/home/apps/jamesgolick.com/public/active_presenter/rdoc` 16 | 17 | puts "Deleting rdoc" 18 | `rm -Rf rdoc` 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Daniel Haran, James Golick, GiraffeSoft, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /active_presenter.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'active_presenter' 3 | s.version = '4.0.0' 4 | s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to?(:required_rubygems_version=) 5 | s.authors = ['James Golick', 'Daniel Haran', 'Josh Martin', 'Johnno Loggie', 'Cedric Darricau'] 6 | s.date = '2012-03-26' 7 | s.extra_rdoc_files = [ 8 | 'LICENSE', 9 | 'README.rdoc' 10 | ] 11 | s.files = [ 12 | 'LICENSE', 13 | 'Gemfile', 14 | 'README', 15 | 'README.rdoc', 16 | 'Rakefile', 17 | 'TODO', 18 | 'active_presenter.gemspec', 19 | 'lib/active_presenter.rb', 20 | 'lib/active_presenter/base.rb', 21 | 'lib/active_presenter/version.rb', 22 | 'lib/tasks/doc.rake', 23 | 'lib/tasks/gem.rake', 24 | 'test/test_helper.rb', 25 | 'test/base_test.rb', 26 | 'test/lint_test.rb' 27 | ] 28 | s.homepage = 'http://github.com/jamesgolick/active_presenter' 29 | s.rdoc_options = ['--charset=UTF-8'] 30 | s.require_paths = ['lib'] 31 | s.rubygems_version = '2.6.13' 32 | s.summary = 'The presenter library you already know.' 33 | s.test_files = [ 34 | 'test/base_test.rb', 35 | 'test/lint_test.rb', 36 | 'test/test_helper.rb' 37 | ] 38 | s.add_runtime_dependency('activerecord', ['>= 3.2', '< 7.0']) 39 | s.add_development_dependency('rake', ['>= 12.0']) 40 | s.add_development_dependency('rspec', ['>= 3.0.0']) 41 | s.add_development_dependency('sqlite3', ['>= 1.3.5']) 42 | s.add_development_dependency('simplecov', ['>= 0.15.0']) 43 | end 44 | -------------------------------------------------------------------------------- /lib/tasks/gem.rake: -------------------------------------------------------------------------------- 1 | require 'rubygems/package_task' 2 | 3 | task :clean => :clobber_package 4 | 5 | spec = Gem::Specification.new do |s| 6 | s.name = ActivePresenter::NAME 7 | s.version = ActivePresenter::VERSION::STRING 8 | s.summary = 9 | s.description = "ActivePresenter is the presenter library you already know! (...if you know ActiveRecord)" 10 | s.author = "James Golick & Daniel Haran" 11 | s.email = 'james@giraffesoft.ca' 12 | s.homepage = 'http://jamesgolick.com/active_presenter' 13 | s.rubyforge_project = 'active_presenter' 14 | s.has_rdoc = true 15 | 16 | s.required_ruby_version = '>= 1.8.5' 17 | 18 | s.files = %w(README LICENSE Rakefile) + 19 | Dir.glob("{lib,test}/**/*") 20 | 21 | s.require_path = "lib" 22 | end 23 | 24 | Gem::PackageTask.new(spec) do |p| 25 | p.gem_spec = spec 26 | end 27 | 28 | task :tag_warn do 29 | puts "*" * 40 30 | puts "Don't forget to tag the release:" 31 | puts 32 | puts " git tag -a v#{ActivePresenter::VERSION::STRING}" 33 | puts 34 | puts "or run rake tag" 35 | puts "*" * 40 36 | end 37 | 38 | task :tag do 39 | sh "git tag -a v#{ActivePresenter::VERSION::STRING}" 40 | end 41 | task :gem => :tag_warn 42 | 43 | namespace :gem do 44 | namespace :upload do 45 | 46 | desc 'Upload gems (ruby & win32) to rubyforge.org' 47 | task :rubyforge => :gem do 48 | sh 'rubyforge login' 49 | sh "rubyforge add_release giraffesoft active_presenter #{ActivePresenter::VERSION::STRING} pkg/#{spec.full_name}.gem" 50 | sh "rubyforge add_file giraffesoft active_presenter #{ActivePresenter::VERSION::STRING} pkg/#{spec.full_name}.gem" 51 | end 52 | 53 | end 54 | end 55 | 56 | task :install => [:clobber, :package] do 57 | sh "sudo gem install pkg/#{spec.full_name}.gem" 58 | end 59 | 60 | task :uninstall => :clean do 61 | sh "sudo gem uninstall -v #{ActivePresenter::VERSION::STRING} -x #{ActivePresenter::NAME}" 62 | end 63 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = ActivePresenter 2 | 3 | ActivePresenter is the presenter library you already know! (...if you know ActiveRecord) 4 | 5 | By acting nearly identically to ActiveRecord models, ActivePresenter makes presenters highly approachable to anybody who is already familiar with ActiveRecord. 6 | 7 | == Get It 8 | 9 | As a gem: 10 | 11 | $ sudo gem install active_presenter 12 | 13 | As a rails gem dependency: 14 | 15 | config.gem 'active_presenter' 16 | 17 | Or get the source from github: 18 | 19 | $ git clone git://github.com/giraffesoft/active_presenter.git 20 | 21 | (or fork it at http://github.com/giraffesoft/active_presenter) 22 | 23 | == Usage 24 | 25 | Creating a presenter is as simple as subclassing ActivePresenter::Base. Use the presents method to indicate which models the presenter should present. 26 | 27 | class SignupPresenter < ActivePresenter::Base 28 | presents :user, :account 29 | end 30 | 31 | In the above example, :user will (predictably) become User. If you want to override this behavior, specify the desired types in a hash, as so: 32 | 33 | class PresenterWithTwoAddresses < ActivePresenter::Base 34 | presents :primary_address => Address, :secondary_address => Address 35 | end 36 | 37 | === Instantiation 38 | 39 | Then, you can instantiate the presenter using either, or both of two forms. 40 | 41 | For example, if you had a SignupPresenter that presented User, and Account, you could specify arguments in the following two forms: 42 | 43 | 1. SignupPresenter.new(:user_login => 'james', :user_password => 'swordfish', :user_password_confirmation => 'swordfish', :account_subdomain => 'giraffesoft') 44 | 45 | - This form is useful for initializing a new presenter from the params hash: i.e. SignupPresenter.new(params[:signup_presenter]) 46 | 47 | 2. SignupPresenter.new(:user => User.find(1), :account => Account.find(2)) 48 | 49 | - This form is useful if you have instances that you'd like to edit using the presenter. You can subsequently call presenter.update_attributes(params[:signup_presenter]) just like with a regular AR instance. 50 | 51 | Both forms can also be mixed together: SignupPresenter.new(:user => User.find(1), :user_login => 'james'). 52 | 53 | In this case, the login attribute will be updated on the user instance provided. 54 | 55 | If you don't specify an instance, one will be created by calling Model.new 56 | 57 | === Validation 58 | 59 | The #valid? method will return true or false based on the validity of the presented objects. 60 | 61 | This is calculated by calling #valid? on them. 62 | 63 | You can retrieve the errors in two ways. 64 | 65 | 1. By calling #errors on the presenter, which returns an instance of ActiveRecord::Errors where all the attributes are in type_name_attribute_name form (i.e. You'd retrieve an error on User#login, by calling @presenter.errors.on(:user_login)). 66 | 67 | 2. By calling @presenter.user_errors, or @presenter.user.errors to retrieve the errors from one presentable. 68 | 69 | Both of these methods are compatible with error_messages_for. It just depends whether you'd like to show all the errors in one block, or whether you'd prefer to break them up. 70 | 71 | === Protected and Accessible Attributes 72 | 73 | ActivePresenter supports +attr_protected+ and +attr_accessible+ just like an ActiveRecord object to avoid mass assignment. This can be leveraged to provide an additional layer of protection at the presenter level. 74 | 75 | class AccountPresenter < ActivePresenter::Base 76 | presents :user, :profile 77 | attr_accessible :user_email, :profile_birthday 78 | end 79 | 80 | === Saving 81 | 82 | You can save your presenter the same way you'd save an ActiveRecord object. Both #save, and #save! behave the same way they do on a normal AR model. 83 | 84 | === Callbacks 85 | 86 | Callbacks work exactly like ActiveRecord callbacks. before_save, and after_save are available. 87 | 88 | Note that if any of your after_save callbacks return false, the rest of them will not be run. This is consistent with AR behavior. 89 | 90 | == Credits 91 | 92 | ActivePresenter was created, and is maintained by {Daniel Haran}[http://danielharan.com] and {James Golick}[http://jamesgolick.com] on the train ride to {RubyFringe}[http://rubyfringe.com] from Montreal. 93 | 94 | ActivePresenter for Rails 4 is currently maintained by {Wayne Duran}[https://github.com/asartalo] and {Cedric Darricau}[http://github.com/devsigner] 95 | 96 | == License 97 | 98 | ActivePresenter is available under the {MIT License}[http://en.wikipedia.org/wiki/MIT_License] 99 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'simplecov' 3 | require 'logger' 4 | 5 | require_relative '../lib/active_presenter' 6 | 7 | ActiveRecord::Base.establish_connection({ 8 | adapter: 'sqlite3', 9 | database: ':memory:' 10 | }) 11 | 12 | ActiveRecord::Base.logger = Logger.new(STDERR) 13 | ActiveRecord::Base.logger.level = Logger::WARN 14 | 15 | RSpec.configure do |config| 16 | config.around do |example| 17 | ActiveRecord::Base.transaction do 18 | example.run 19 | raise ActiveRecord::Rollback 20 | end 21 | end 22 | end 23 | 24 | I18n.backend.store_translations('1337', 25 | activerecord: { 26 | models: { 27 | user: 'U53R' 28 | }, 29 | attributes: { 30 | user: { password: 'pa22w0rD' } 31 | }, 32 | errors: { 33 | messages: { 34 | blank: 'c4N n07 83 8L4nK' 35 | } 36 | } 37 | } 38 | ) 39 | 40 | def hash_for_user(opts = {}) 41 | { login: 'jane', password: 'seekrit' }.merge(opts) 42 | end 43 | 44 | def returning(value) 45 | yield(value) 46 | value 47 | end 48 | 49 | ActiveRecord::Schema.define(version: 0) do 50 | create_table :users do |t| 51 | t.boolean :admin, default: false 52 | t.string :login, default: '' 53 | t.string :password, default: '' 54 | t.datetime :birthday 55 | end 56 | 57 | create_table :accounts do |t| 58 | t.string :subdomain, default: '' 59 | t.string :title, default: '' 60 | t.string :secret, default: '' 61 | end 62 | 63 | create_table :addresses do |t| 64 | t.string :street 65 | end 66 | 67 | create_table :account_infos do |t| 68 | t.string :info 69 | end 70 | 71 | create_table :histories do |t| 72 | t.integer :user_id 73 | t.string :comment, default: '' 74 | t.string :action, default: '' 75 | t.datetime :created_at 76 | end 77 | end 78 | 79 | class User < ActiveRecord::Base 80 | validates_presence_of :login 81 | validate :presence_of_password 82 | attr_accessor :password_confirmation 83 | 84 | def presence_of_password 85 | if password.blank? 86 | attribute_name = I18n.t(:password, {default: 'Password', scope: [:activerecord, :attributes, :user]}) 87 | error_message = I18n.t(:blank, { default: "can't be blank", scope: [:activerecord, :errors, :messages] }) 88 | errors[:base] << "#{attribute_name} #{error_message}" 89 | end 90 | end 91 | end 92 | class Account < ActiveRecord::Base; end 93 | class History < ActiveRecord::Base; end 94 | class Address < ActiveRecord::Base; end 95 | class AccountInfo < ActiveRecord::Base; end 96 | 97 | class PresenterWithTwoAddresses < ActivePresenter::Base 98 | presents :address, secondary_address: Address 99 | end 100 | 101 | class DecoratedUser < ActivePresenter::Base 102 | decorates :user 103 | end 104 | 105 | class DecoratedUserWithTags < ActivePresenter::Base 106 | decorates :user 107 | attr_accessor :tags 108 | 109 | validates :tags, presence: true 110 | end 111 | 112 | class SignupPresenter < ActivePresenter::Base 113 | presents :account, :user 114 | end 115 | 116 | class EndingWithSPresenter < ActivePresenter::Base 117 | presents :address 118 | end 119 | 120 | class HistoricalPresenter < ActivePresenter::Base 121 | presents :user, :history 122 | end 123 | 124 | class CantSavePresenter < ActivePresenter::Base 125 | presents :address 126 | 127 | before_save :abort 128 | 129 | def abort 130 | # throw(:abort) # rails 5 131 | false 132 | end 133 | end 134 | 135 | class SignupNoAccountPresenter < ActivePresenter::Base 136 | presents :account, :user 137 | 138 | def save?(key, _instance) 139 | key.to_sym != :account 140 | end 141 | end 142 | 143 | class AfterSavePresenter < ActivePresenter::Base 144 | presents :address 145 | 146 | after_save :set_street 147 | 148 | def set_street 149 | address.street = 'Some Street' 150 | end 151 | end 152 | 153 | class SamePrefixPresenter < ActivePresenter::Base 154 | presents :account, :account_info 155 | end 156 | 157 | class CallbackOrderingPresenter < ActivePresenter::Base 158 | presents :account 159 | 160 | before_validation :do_before_validation 161 | before_save :do_before_save 162 | after_save :do_after_save 163 | 164 | attr_reader :steps 165 | 166 | def initialize(params = {}) 167 | super 168 | @steps = [] 169 | end 170 | 171 | def do_before_validation 172 | @steps << :before_validation 173 | end 174 | 175 | def do_before_save 176 | @steps << :before_save 177 | end 178 | 179 | def do_after_save 180 | @steps << :after_save 181 | end 182 | end 183 | 184 | class CallbackCantSavePresenter < ActivePresenter::Base 185 | presents :account 186 | 187 | before_validation :do_before_validation 188 | before_save :do_before_save 189 | before_save :halt 190 | after_save :do_after_save 191 | 192 | attr_reader :steps 193 | 194 | def initialize(params = {}) 195 | super 196 | @steps = [] 197 | end 198 | 199 | def do_before_validation 200 | @steps << :before_validation 201 | end 202 | 203 | def do_before_save 204 | @steps << :before_save 205 | end 206 | 207 | def do_after_save 208 | @steps << :after_save 209 | end 210 | 211 | def halt 212 | false 213 | end 214 | end 215 | 216 | class CallbackCantValidatePresenter < ActivePresenter::Base 217 | presents :account 218 | 219 | before_validation :do_before_validation 220 | before_validation :halt 221 | before_save :do_before_save 222 | after_save :do_after_save 223 | 224 | attr_reader :steps 225 | 226 | def initialize(params = {}) 227 | super 228 | @steps = [] 229 | end 230 | 231 | def do_before_validation 232 | @steps << :before_validation 233 | end 234 | 235 | def do_before_save 236 | @steps << :before_save 237 | end 238 | 239 | def do_after_save 240 | @steps << :after_save 241 | end 242 | 243 | def halt 244 | false 245 | end 246 | end 247 | 248 | class HistoricalPresenter < ActivePresenter::Base 249 | presents :user, :history 250 | end 251 | -------------------------------------------------------------------------------- /lib/active_presenter/base.rb: -------------------------------------------------------------------------------- 1 | module ActivePresenter 2 | # Base class for presenters. See README for usage. 3 | # 4 | class Base 5 | extend ActiveModel::Callbacks 6 | extend ActiveModel::Naming 7 | extend ActiveModel::Translation 8 | include ActiveModel::Conversion 9 | include ActiveModel::Validations 10 | 11 | define_model_callbacks :validation, :save 12 | 13 | class_attribute :presented, :decorated 14 | self.presented = {} 15 | self.decorated = nil 16 | 17 | # Indicates which models are to be presented by this presenter. 18 | # i.e. 19 | # 20 | # class SignupPresenter < ActivePresenter::Base 21 | # presents :user, :account 22 | # end 23 | # 24 | # In the above example, :user will (predictably) become User. If you want to override this behaviour, specify the desired types in a hash, as so: 25 | # 26 | # class PresenterWithTwoAddresses < ActivePresenter::Base 27 | # presents :primary_address => Address, :secondary_address => Address 28 | # end 29 | # 30 | def self.presents(*types) 31 | types_and_classes = types.extract_options! 32 | types.each { |t| types_and_classes[t] = t.to_s.tableize.classify.constantize } 33 | 34 | attr_accessor *types_and_classes.keys 35 | 36 | types_and_classes.keys.each do |t| 37 | define_method("#{t}_errors") do 38 | send(t).errors 39 | end 40 | 41 | # We must reassign in derrived classes rather than mutating the attribute in Base 42 | self.presented = self.presented.merge(t => types_and_classes[t]) 43 | end 44 | end 45 | 46 | 47 | # Use Presenter as Decorator 48 | # 49 | # This effectively removes type prefixes on its attributes accessors 50 | # 51 | # class DecoratedUser < ActivePresenter::Base 52 | # decorates :user 53 | # end 54 | # 55 | # In the above example, :user will (predictably) become User. 56 | # 57 | def self.decorates(type) 58 | presents(type) 59 | self.decorated = type 60 | end 61 | 62 | def self.human_attribute_name(attribute_key_name, options = {}) 63 | presentable_type = presented.keys.detect do |type| 64 | if decorated == type 65 | true 66 | else 67 | attribute_key_name.to_s.starts_with?("#{type}_") || attribute_key_name.to_s == type.to_s 68 | end 69 | end 70 | attribute_key_name_without_class = attribute_key_name.to_s.gsub("#{presentable_type}_", "") 71 | 72 | if presented[presentable_type] and attribute_key_name_without_class != presentable_type.to_s 73 | presented[presentable_type].human_attribute_name(attribute_key_name_without_class, options) 74 | else 75 | I18n.translate(presentable_type, options.merge(:default => presentable_type.to_s.humanize, :scope => [:activerecord, :models])) 76 | end 77 | end 78 | 79 | # Accepts arguments in two forms. For example, if you had a SignupPresenter that presented User, and Account, you could specify arguments in the following two forms: 80 | # 81 | # 1. SignupPresenter.new(:user_login => 'james', :user_password => 'swordfish', :user_password_confirmation => 'swordfish', :account_subdomain => 'giraffesoft') 82 | # - This form is useful for initializing a new presenter from the params hash: i.e. SignupPresenter.new(params[:signup_presenter]) 83 | # 2. SignupPresenter.new(:user => User.find(1), :account => Account.find(2)) 84 | # - This form is useful if you have instances that you'd like to edit using the presenter. You can subsequently call presenter.update_attributes(params[:signup_presenter]) just like with a regular AR instance. 85 | # 86 | # Both forms can also be mixed together: SignupPresenter.new(:user => User.find(1), :user_login => 'james') 87 | # In this case, the login attribute will be updated on the user instance provided. 88 | # 89 | # If you don't specify an instance, one will be created by calling Model.new 90 | # 91 | def initialize(args = {}) 92 | @errors = ActiveModel::Errors.new(self) 93 | return self unless args 94 | presented.each do |type, klass| 95 | value = args.delete(type) 96 | send("#{type}=", value.is_a?(klass) ? value : klass.new) 97 | end 98 | self.attributes = args 99 | end 100 | 101 | # Set the attributes of the presentable instances using 102 | # the type_attribute form (i.e. user_login => 'james'), or 103 | # the multiparameter attribute form (i.e. {user_birthday(1i) => "1980", user_birthday(2i) => "3"}) 104 | # 105 | def attributes=(attrs) 106 | return if attrs.nil? 107 | 108 | attrs = attrs.stringify_keys 109 | multi_parameter_attributes = {} 110 | 111 | attrs.each do |k,v| 112 | if (base_attribute = k.to_s.split("(").first) != k.to_s 113 | presentable = presentable_for(base_attribute) 114 | multi_parameter_attributes[presentable] ||= {} 115 | multi_parameter_attributes[presentable].merge!(flatten_attribute_name(k,presentable).to_sym => v) 116 | else 117 | send("#{k}=", v) unless attribute_protected?(k) 118 | end 119 | end 120 | 121 | multi_parameter_attributes.each do |presentable,multi_attrs| 122 | send(presentable).send(:attributes=, multi_attrs) 123 | end 124 | end 125 | 126 | # Makes sure that the presenter is accurate about responding to presentable's attributes, even though they are handled by method_missing. 127 | # 128 | def respond_to?(method, include_private = false) 129 | presented_attribute?(method) || super 130 | end 131 | 132 | # Handles the decision about whether to delegate getters and setters to presentable instances. 133 | # 134 | def method_missing(method_name, *args, &block) 135 | presented_attribute?(method_name) ? delegate_message(method_name, *args, &block) : super 136 | end 137 | 138 | alias_method :itself_valid?, :valid? 139 | 140 | # Returns boolean based on the validity of the presentables by calling valid? on each of them. 141 | # 142 | def valid? 143 | validated = false 144 | errors.clear 145 | itself_valid? 146 | result = run_callbacks :validation do 147 | presented.keys.each do |type| 148 | presented_inst = send(type) 149 | next unless save?(type, presented_inst) 150 | merge_errors(presented_inst, type) unless presented_inst.valid? 151 | end 152 | validated = true 153 | end 154 | errors.empty? && validated 155 | end 156 | 157 | # Do any of the attributes have unsaved changes? 158 | def changed? 159 | presented_instances.map(&:changed?).any? 160 | end 161 | 162 | # Save all of the presentables, wrapped in a transaction. 163 | # 164 | # Returns true or false based on success. 165 | # 166 | def save 167 | saved = false 168 | ActiveRecord::Base.transaction do 169 | if valid? 170 | run_callbacks :save do 171 | saved = presented.keys.select {|key| save?(key, send(key))}.all? {|key| send(key).save} 172 | raise ActiveRecord::Rollback unless saved 173 | end 174 | end 175 | end 176 | saved 177 | end 178 | 179 | # Save all of the presentables wrapped in a transaction. 180 | # 181 | # Returns true on success, will raise otherwise. 182 | # 183 | def save! 184 | saved = false 185 | ActiveRecord::Base.transaction do 186 | raise ActiveRecord::RecordInvalid.new(self) unless valid? 187 | run_callbacks :save do 188 | presented.keys.select {|key| save?(key, send(key))}.all? {|key| send(key).save!} 189 | saved = true 190 | end 191 | raise ActiveRecord::RecordNotSaved.new(self) unless saved 192 | end 193 | saved 194 | end 195 | 196 | # Update attributes, and save the presentables 197 | # 198 | # Returns true or false based on success. 199 | # 200 | def update_attributes(attrs) 201 | self.attributes = attrs 202 | save 203 | end 204 | 205 | # Should this presented instance be saved? By default, this returns true 206 | # Called from #save and #save! 207 | # 208 | # For 209 | # class SignupPresenter < ActivePresenter::Base 210 | # presents :account, :user 211 | # end 212 | # 213 | # #save? will be called twice: 214 | # save?(:account, #) 215 | # save?(:user, #) 216 | def save?(presented_key, presented_instance) 217 | true 218 | end 219 | 220 | # We define #id and #new_record? to play nice with form_for(@presenter) in Rails 221 | def id # :nodoc: 222 | nil 223 | end 224 | 225 | def new_record? 226 | presented_instances.map(&:new_record?).all? 227 | end 228 | 229 | def persisted? 230 | presented_instances.map(&:persisted?).all? 231 | end 232 | 233 | protected 234 | 235 | def presented_instances 236 | presented.keys.map { |key| send(key) } 237 | end 238 | 239 | def delegate_message(method_name, *args, &block) 240 | presentable = presentable_for(method_name) 241 | send(presentable).send(flatten_attribute_name(method_name, presentable), *args, &block) 242 | end 243 | 244 | def presentable_for(method_name) 245 | if decorated and presented[decorated].method_defined?(method_name) 246 | return decorated 247 | end 248 | presented.keys.sort_by { |k| k.to_s.size }.reverse.detect do |type| 249 | method_name.to_s.starts_with?(attribute_prefix(type)) 250 | end 251 | end 252 | 253 | def presented_attribute?(method_name) 254 | p = presentable_for(method_name) 255 | !p.nil? && send(p).respond_to?(flatten_attribute_name(method_name,p)) 256 | end 257 | 258 | def flatten_attribute_name(name, type) 259 | name.to_s.gsub(/^#{attribute_prefix(type)}/, '') 260 | end 261 | 262 | def attribute_prefix(type) 263 | decorated == type ? '' : "#{type}_" 264 | end 265 | 266 | def merge_errors(presented_inst, type) 267 | presented_inst.errors.each do |att,msg| 268 | if att == :base 269 | errors.add(type, msg) 270 | else 271 | errors.add(attribute_prefix(type)+att.to_s, msg) 272 | end 273 | end 274 | end 275 | 276 | def attribute_protected?(name) 277 | return false if presentable_for(name) 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /spec/base_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe ActivePresenter::Base do 4 | context "when SignupPresenter is set as new" do 5 | let(:presenter) { SignupPresenter.new } 6 | 7 | it "returns no ID" do 8 | expect(presenter.id).to be nil 9 | end 10 | 11 | it "is a new record" do 12 | expect(presenter.new_record?).to be true 13 | end 14 | 15 | it "is not be valid" do 16 | expect(presenter).not_to be_valid 17 | expect(SignupPresenter.new(user: User.new(hash_for_user))).to be_valid 18 | end 19 | 20 | it { expect(presenter.respond_to?(:user_login)).to be } 21 | it { expect(presenter.respond_to?(:user_password_confirmation)).to be } 22 | it { expect(presenter.respond_to?(:valid?)).to be } # just making sure i didn't break everything :) 23 | it { expect(presenter.respond_to?(:nil?, false)).to be } # making sure it's possible to pass 2 arguments 24 | 25 | it { expect(presenter.attributes = nil).to eq nil } 26 | 27 | it { expect(SignupPresenter.human_attribute_name(:user_login)).to eq('Login') } 28 | 29 | it { expect { SignupPresenter.new({:i_dont_exist=>"blah"}) }.to raise_error(NoMethodError) } 30 | 31 | it { expect(presenter.changed?).to be_falsy } 32 | it { 33 | p = SignupPresenter.new(:user => User.new(hash_for_user)) 34 | p.save 35 | p.user_login = 'something_else' 36 | expect(p.changed?).to be 37 | } 38 | 39 | it "returns an ActiveRecord::RecordInvalid" do 40 | expect { presenter.save! }.to raise_error(ActiveRecord::RecordInvalid) 41 | end 42 | 43 | it "was raising with nil" do 44 | expect(SignupPresenter.new(nil).class).to eq SignupPresenter 45 | end 46 | 47 | context "when it try to set with an unexisting params" do 48 | it "raise a NoMethodError" do 49 | expect{ SignupPresenter.new({:i_dont_exist=>"blah"}) }.to raise_error(NoMethodError) 50 | end 51 | end 52 | 53 | context "on validation" do 54 | before do 55 | presenter.valid? 56 | end 57 | 58 | it "returns an ActiveModel::Errors" do 59 | expect(presenter.errors.class).to be ActiveModel::Errors 60 | expect(presenter.user_errors.class).to be ActiveModel::Errors 61 | expect(presenter.account_errors.class).to be ActiveModel::Errors 62 | end 63 | 64 | it "returns a error message" do 65 | expect(presenter.errors[:user_login]).to eq(["can't be blank"]) 66 | end 67 | 68 | it "returns full messages" do 69 | expect(presenter.errors.full_messages).to eq(["User login can't be blank", "User Password can't be blank"]) 70 | end 71 | 72 | context "when user password is set" do 73 | let(:presenter) { SignupPresenter.new(user_password: 'foo', user_password_confirmation: 'foo') } 74 | 75 | it "returns full messages" do 76 | expect(presenter.errors.full_messages).to eq(["User login can't be blank"]) 77 | end 78 | end 79 | 80 | context "when locale change" do 81 | it "returns localize error message" do 82 | message = when_locale_changed do 83 | s = SignupPresenter.new(user_login: nil) 84 | s.valid? 85 | s.errors[:user_login] 86 | end 87 | 88 | expect(message).to eq(['c4N n07 83 8L4nK']) 89 | end 90 | 91 | it "returns localize full error messages" do 92 | message = when_locale_changed do 93 | s = SignupPresenter.new(user_login: 'login') 94 | s.valid? 95 | s.errors.full_messages 96 | end 97 | 98 | expect(message).to eq(['U53R pa22w0rD c4N n07 83 8L4nK']) 99 | end 100 | end 101 | end 102 | 103 | context "User login" do 104 | it "sets login User" do 105 | expect_any_instance_of(User).to receive(:login=).with('james') 106 | SignupPresenter.new(user_login: 'james') 107 | end 108 | 109 | it "returns the user_login value" do 110 | expect_any_instance_of(User).to receive(:login).and_return('mymockvalue') 111 | SignupPresenter.new.user_login 112 | end 113 | 114 | it "use `user_login` to set login's User" do 115 | expect_any_instance_of(User).to receive(:login=).with('mymockvalue') 116 | SignupPresenter.new.user_login = 'mymockvalue' 117 | end 118 | end 119 | 120 | context "when it try to saved the presenter" do 121 | it { expect(presenter.save).not_to be } 122 | 123 | it "uses a transaction" do 124 | expect(ActiveRecord::Base).to receive(:transaction) 125 | presenter.save 126 | end 127 | 128 | it "returns error message" do 129 | presenter.save 130 | expect(presenter.errors[:user_login]).to eq(["can't be blank"]) 131 | end 132 | 133 | context "when the user is set" do 134 | let(:presenter) { SignupPresenter.new(user: User.new(hash_for_user)) } 135 | 136 | it "try to save the user" do 137 | expect_any_instance_of(User).to receive(:save) 138 | presenter.save 139 | end 140 | 141 | it "try to save the account" do 142 | expect_any_instance_of(Account).to receive(:save) 143 | presenter.save 144 | end 145 | #it "rollbacks" do 146 | #allow(ActiveRecord::Base).to receive(:transaction).and_yield 147 | #expect_any_instance_of(User).to receive(:save).and_return(false) 148 | #expect_any_instance_of(Account).to receive(:save).and_return(false) 149 | 150 | #expect(presenter.save).to be ActiveRecord::Rollback 151 | #end 152 | end 153 | end 154 | 155 | context "when it try to saved! the presenter" do 156 | it "returns error message" do 157 | presenter.save! rescue 158 | expect(presenter.errors[:user_login]).to eq(["can't be blank"]) 159 | end 160 | 161 | context "when user_login and user_password is set" do 162 | let(:presenter) { SignupPresenter.new(user_login: "da", user_password: "seekrit") } 163 | 164 | it "uses a transaction" do 165 | expect(ActiveRecord::Base).to receive(:transaction) 166 | presenter.save! 167 | end 168 | 169 | it "try to save the user" do 170 | expect_any_instance_of(User).to receive(:save) 171 | presenter.save 172 | end 173 | 174 | it "try to save the account" do 175 | expect_any_instance_of(Account).to receive(:save) 176 | presenter.save 177 | end 178 | end 179 | 180 | context "the user is valid" do 181 | let(:presenter) { SignupPresenter.new(user: User.new(hash_for_user)) } 182 | 183 | it "saves the user" do 184 | expect(presenter.save!).to be 185 | end 186 | end 187 | end 188 | 189 | context "when update_attributes" do 190 | it "set all attributes" do 191 | presenter.update_attributes(user_login: 'Something Different') 192 | expect(presenter.user_login).to eq('Something Different') 193 | end 194 | 195 | it "can use multiparameter assignment" do 196 | presenter.update_attributes({ 197 | :"user_birthday(1i)" => '1980', 198 | :"user_birthday(2i)" => '3', 199 | :"user_birthday(3i)" => '27', 200 | :"user_birthday(4i)" => '9', 201 | :"user_birthday(5i)" => '30', 202 | :"user_birthday(6i)" => '59' 203 | }) 204 | expect(presenter.user_birthday).to eq(Time.parse('March 27 1980 9:30:59 am UTC')) 205 | end 206 | 207 | context "when user exist" do 208 | let(:user) { User.create!(hash_for_user) } 209 | let(:presenter) { SignupPresenter.new(user: user) } 210 | 211 | it "does not changed login" do 212 | presenter.update_attributes(user_login: 'Something Totally Different') 213 | expect(user).not_to be_login_changed 214 | end 215 | 216 | it "try to save the presenter" do 217 | expect(presenter).to receive(:save) 218 | presenter.update_attributes user_login: 'Something' 219 | end 220 | end 221 | end 222 | end 223 | 224 | context "when presenter have after some callbacks" do 225 | it "calls it" do 226 | %w(save save!).each do |save_method| 227 | presenter = AfterSavePresenter.new 228 | presenter.send(save_method) 229 | expect(presenter.address.street).to eq('Some Street') 230 | end 231 | end 232 | 233 | it { expect { CallbackCantSavePresenter.new.save! }.to raise_error(ActiveRecord::RecordNotSaved) } 234 | 235 | context "when presenter cant validate the presenter" do 236 | let(:cant_save_presenter) { CallbackCantValidatePresenter.new } 237 | 238 | it { expect { cant_save_presenter.save! }.to raise_error(ActiveRecord::RecordInvalid) } 239 | 240 | it "returns ordering callback list for save method" do 241 | expect([:before_validation]).to eq( 242 | returning(cant_save_presenter) do |presenter| 243 | begin 244 | presenter.save 245 | rescue ActiveRecord::RecordInvalid 246 | # NOP 247 | end 248 | end.steps 249 | ) 250 | end 251 | 252 | it "returns ordering callback list for save! method" do 253 | expect([:before_validation]).to eq( 254 | returning(cant_save_presenter) do |presenter| 255 | begin 256 | presenter.save! 257 | rescue ActiveRecord::RecordInvalid 258 | # NOP 259 | end 260 | end.steps 261 | ) 262 | end 263 | 264 | it "clear Errors before validation" do 265 | expect_any_instance_of(ActiveModel::Errors).to receive(:clear).at_least(:once) 266 | cant_save_presenter.valid? 267 | end 268 | end 269 | 270 | context "ordering callbacks" do 271 | let(:presenter) { CallbackOrderingPresenter.new } 272 | 273 | it "calls callbacks" do 274 | expect([:before_validation, :before_save, :after_save]).to eq( 275 | returning(presenter) do |pres| 276 | pres.save! 277 | end.steps 278 | ) 279 | 280 | expect([:before_validation, :before_save, :after_save, :before_validation, :before_save, :after_save]).to eq( 281 | returning(presenter) do |pres| 282 | pres.save 283 | end.steps 284 | ) 285 | 286 | expect([:before_validation, :before_save, :after_save, :before_validation, :before_save, :after_save, :before_validation, :before_save, :after_save]).to eq( 287 | returning(presenter) do |pres| 288 | begin 289 | pres.save! 290 | rescue ActiveRecord::RecordNotSaved 291 | # NOP 292 | end 293 | end.steps 294 | ) 295 | end 296 | end 297 | end 298 | 299 | context "When is not set" do 300 | it { expect(SignupNoAccountPresenter.new.save).not_to be } 301 | it { expect(SignupNoAccountPresenter.new(:user => User.new(hash_for_user), :account => nil).save).to be } 302 | it { expect(SignupNoAccountPresenter.new(:user => User.new(hash_for_user), :account => nil).save!).to be } 303 | end 304 | 305 | context "when presenter have 2 addresses" do 306 | it "returns secondary address" do 307 | expect(PresenterWithTwoAddresses.new.secondary_address.class).to eq(Address) 308 | end 309 | 310 | it "return secondary address street value" do 311 | p = PresenterWithTwoAddresses.new(:secondary_address_street => "123 awesome st") 312 | p.save 313 | expect(p.secondary_address_street).to eq("123 awesome st") 314 | end 315 | end 316 | 317 | context "when use Presenter as Decorator" do 318 | it { expect(DecoratedUser.new).not_to be_valid } 319 | 320 | it "Effectively removes type prefixes on attributes" do 321 | expect(DecoratedUser.new.user.class).to eq(User) 322 | end 323 | 324 | it "return attribute value" do 325 | expect_any_instance_of(User).to receive(:login).and_return('mymockvalue') 326 | expect(DecoratedUser.new.login).to eq('mymockvalue') 327 | end 328 | 329 | it "sets attribute value" do 330 | expect_any_instance_of(User).to receive(:login=).with('mymockvalue') 331 | DecoratedUser.new.login = 'mymockvalue' 332 | end 333 | 334 | it "returns error message" do 335 | u = DecoratedUser.new 336 | u.valid? 337 | expect(u.errors[:login]).to eq(["can't be blank"]) 338 | end 339 | 340 | it "returns full error message" do 341 | u = DecoratedUser.new(:password => 'foo', :password_confirmation => 'foo') 342 | u.valid? 343 | expect(u.errors.full_messages).to eq(["Login can't be blank"]) 344 | end 345 | 346 | context "when it try to save" do 347 | it { expect{ DecoratedUser.new.save! }.to raise_error(ActiveRecord::RecordInvalid) } 348 | 349 | it { expect(DecoratedUser.new.save).not_to be } 350 | 351 | it "uses a transaction" do 352 | expect(ActiveRecord::Base).to receive(:transaction) 353 | DecoratedUser.new.save 354 | end 355 | 356 | it "forward save to User" do 357 | expect_any_instance_of(User).to receive(:save) 358 | DecoratedUser.new :user => User.new(hash_for_user).save 359 | end 360 | 361 | context "when login and password are set" do 362 | let(:decorated_user) { DecoratedUser.new(:login => "da", :password => "seekrit") } 363 | 364 | it { expect(DecoratedUser.new(:user => User.new(hash_for_user)).save!).to be } 365 | 366 | it "uses a transaction" do 367 | expect(ActiveRecord::Base).to receive(:transaction) 368 | decorated_user.save 369 | end 370 | 371 | it "forward save! to User" do 372 | expect_any_instance_of(User).to receive(:save!) 373 | decorated_user.save! 374 | end 375 | end 376 | end 377 | end 378 | 379 | context "when DecoratedUserWithTags" do 380 | let(:decorator) { DecoratedUserWithTags.new :user => User.new(hash_for_user) } 381 | 382 | context "without tags" do 383 | it "does not be valid" do 384 | expect(decorator.valid?).to be_falsy 385 | end 386 | 387 | it "returns error message" do 388 | decorator.valid? 389 | expect(decorator.errors[:tags]).to eq(["can't be blank"]) 390 | end 391 | end 392 | 393 | context "with tags" do 394 | context "when user is new User" do 395 | it "valids" do 396 | decorator_with_tags = DecoratedUserWithTags.new :user => User.new(hash_for_user) 397 | decorator.tags = "Tall, Mammal" 398 | 399 | expect(decorator_with_tags).to be_truthy 400 | end 401 | end 402 | 403 | context "when set with hash params" do 404 | it "valids" do 405 | decorator_with_tags = DecoratedUserWithTags.new hash_for_user 406 | decorator.tags = "Tall, Mammal" 407 | 408 | expect(decorator_with_tags).to be_truthy 409 | end 410 | end 411 | 412 | context "when set with hash params with tags key" do 413 | it "valids" do 414 | decorator_with_tags = DecoratedUserWithTags.new hash_for_user.merge({tags: "Tall, Mammal"}) 415 | 416 | expect(decorator_with_tags).to be_truthy 417 | end 418 | end 419 | end 420 | end 421 | 422 | it { expect(EndingWithSPresenter.new.address).not_to be_nil } 423 | 424 | it { expect(CantSavePresenter.new.save).not_to be } # it won't save because the filter chain will abort 425 | it { expect{ CantSavePresenter.new.save! }.to raise_error(ActiveRecord::RecordNotSaved) } 426 | 427 | it { expect(SamePrefixPresenter.new.respond_to?(:account_title)).to be } 428 | it { expect(SamePrefixPresenter.new.respond_to?(:account_info_info) ).to be } 429 | 430 | it { 431 | p = HistoricalPresenter.new(:history_comment => 'comment', :user => User.new(hash_for_user)) 432 | p.save 433 | 434 | expect(p.history_comment).to eq("comment") 435 | } 436 | end 437 | 438 | def when_locale_changed 439 | old_locale = I18n.locale 440 | I18n.locale = '1337' 441 | value = yield 442 | I18n.locale = old_locale 443 | value 444 | end 445 | --------------------------------------------------------------------------------