├── README ├── .gitignore ├── TODO ├── lib ├── active_presenter │ ├── version.rb │ └── base.rb ├── active_presenter.rb └── tasks │ ├── doc.rake │ └── gem.rake ├── Rakefile ├── LICENSE ├── README.rdoc └── test ├── test_helper.rb └── base_test.rb /README: -------------------------------------------------------------------------------- 1 | README.rdoc -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | rdoc 2 | pkg -------------------------------------------------------------------------------- /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 = 1 4 | MINOR = 3 5 | TINY = 0 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 'rake/rdoctask' 3 | require File.dirname(__FILE__)+'/lib/active_presenter' 4 | Dir.glob(File.dirname(__FILE__)+'/lib/tasks/**/*.rake').each { |l| load l } 5 | 6 | task :default => :test 7 | 8 | task :test do 9 | Dir['test/**/*_test.rb'].each { |l| require l } 10 | end 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/tasks/gem.rake: -------------------------------------------------------------------------------- 1 | require 'rake/gempackagetask' 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 | Rake::GemPackageTask.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 behaviour, 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 | === Saving 72 | 73 | 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. 74 | 75 | === Callbacks 76 | 77 | Callbacks work exactly like ActiveRecord callbacks. before_save, and after_save are available. 78 | 79 | 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. 80 | 81 | == Credits 82 | 83 | 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. 84 | 85 | == License 86 | 87 | ActivePresenter is available under the {MIT License}[http://en.wikipedia.org/wiki/MIT_License] 88 | 89 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__)+'/../lib/active_presenter' unless defined?(ActivePresenter) 2 | require 'expectations' 3 | require 'logger' 4 | 5 | ActiveRecord::Base.configurations = {'sqlite3' => {:adapter => 'sqlite3', :database => ':memory:'}} 6 | ActiveRecord::Base.establish_connection('sqlite3') 7 | 8 | ActiveRecord::Base.logger = Logger.new(STDERR) 9 | ActiveRecord::Base.logger.level = Logger::WARN 10 | 11 | I18n.backend.store_translations '1337', 12 | :activerecord => { 13 | :models => { 14 | :user => 'U53R' 15 | }, 16 | :attributes => { 17 | :user => {:password => 'pa22w0rD'} 18 | }, 19 | :errors => { 20 | :messages => { 21 | :blank => 'c4N n07 83 8L4nK' 22 | } 23 | } 24 | } 25 | 26 | ActiveRecord::Schema.define(:version => 0) do 27 | create_table :users do |t| 28 | t.boolean :admin, :default => false 29 | t.string :login, :default => '' 30 | t.string :password, :default => '' 31 | t.datetime :birthday 32 | end 33 | 34 | create_table :accounts do |t| 35 | t.string :subdomain, :default => '' 36 | t.string :title, :default => '' 37 | t.string :secret, :default => '' 38 | end 39 | 40 | create_table :addresses do |t| 41 | t.string :street 42 | end 43 | 44 | create_table :account_infos do |t| 45 | t.string :info 46 | end 47 | 48 | create_table :histories do |t| 49 | t.integer :user_id 50 | t.string :comment, :default => '' 51 | t.string :action, :default => '' 52 | t.datetime :created_at 53 | end 54 | 55 | end 56 | 57 | class User < ActiveRecord::Base 58 | validates_presence_of :login 59 | validate :presence_of_password 60 | attr_accessible :login, :password, :birthday 61 | attr_accessor :password_confirmation 62 | 63 | def presence_of_password 64 | if password.blank? 65 | attribute_name = I18n.t(:password, {:default => "Password", :scope => [:activerecord, :attributes, :user]}) 66 | error_message = I18n.t(:blank, {:default => "can't be blank", :scope => [:activerecord, :errors, :messages]}) 67 | errors.add_to_base("#{attribute_name} #{error_message}") 68 | end 69 | end 70 | end 71 | class Account < ActiveRecord::Base; end 72 | class History < ActiveRecord::Base; end 73 | class Address < ActiveRecord::Base; end 74 | class AccountInfo < ActiveRecord::Base; end 75 | 76 | class PresenterWithTwoAddresses < ActivePresenter::Base 77 | presents :address, :secondary_address => Address 78 | end 79 | 80 | class SignupPresenter < ActivePresenter::Base 81 | presents :account, :user 82 | attr_protected :account_secret 83 | end 84 | 85 | class EndingWithSPresenter < ActivePresenter::Base 86 | presents :address 87 | end 88 | 89 | class HistoricalPresenter < ActivePresenter::Base 90 | presents :user, :history 91 | attr_accessible :history_comment 92 | end 93 | 94 | class CantSavePresenter < ActivePresenter::Base 95 | presents :address 96 | 97 | before_save :halt 98 | 99 | def halt; false; end 100 | end 101 | 102 | class SignupNoNilPresenter < ActivePresenter::Base 103 | presents :account, :user 104 | 105 | def save?(key, instance) 106 | !instance.nil? 107 | end 108 | end 109 | 110 | class AfterSavePresenter < ActivePresenter::Base 111 | presents :address 112 | 113 | after_save :set_street 114 | 115 | def set_street 116 | address.street = 'Some Street' 117 | end 118 | end 119 | 120 | class SamePrefixPresenter < ActivePresenter::Base 121 | presents :account, :account_info 122 | end 123 | 124 | class CallbackOrderingPresenter < ActivePresenter::Base 125 | presents :account 126 | 127 | before_validation :do_before_validation 128 | before_save :do_before_save 129 | after_save :do_after_save 130 | 131 | attr_reader :steps 132 | 133 | def initialize(params={}) 134 | super 135 | @steps = [] 136 | end 137 | 138 | def do_before_validation 139 | @steps << :before_validation 140 | end 141 | 142 | def do_before_save 143 | @steps << :before_save 144 | end 145 | 146 | def do_after_save 147 | @steps << :after_save 148 | end 149 | end 150 | 151 | class CallbackCantSavePresenter < ActivePresenter::Base 152 | presents :account 153 | 154 | before_validation :do_before_validation 155 | before_save :do_before_save 156 | before_save :halt 157 | after_save :do_after_save 158 | 159 | attr_reader :steps 160 | 161 | def initialize(params={}) 162 | super 163 | @steps = [] 164 | end 165 | 166 | def do_before_validation 167 | @steps << :before_validation 168 | end 169 | 170 | def do_before_save 171 | @steps << :before_save 172 | end 173 | 174 | def do_after_save 175 | @steps << :after_save 176 | end 177 | 178 | def halt 179 | false 180 | end 181 | end 182 | 183 | class CallbackCantValidatePresenter < ActivePresenter::Base 184 | presents :account 185 | 186 | before_validation :do_before_validation 187 | before_validation :halt 188 | before_save :do_before_save 189 | after_save :do_after_save 190 | 191 | attr_reader :steps 192 | 193 | def initialize(params={}) 194 | super 195 | @steps = [] 196 | end 197 | 198 | def do_before_validation 199 | @steps << :before_validation 200 | end 201 | 202 | def do_before_save 203 | @steps << :before_save 204 | end 205 | 206 | def do_after_save 207 | @steps << :after_save 208 | end 209 | 210 | def halt 211 | false 212 | end 213 | end 214 | 215 | def hash_for_user(opts = {}) 216 | {:login => 'jane', :password => 'seekrit' }.merge(opts) 217 | end 218 | 219 | -------------------------------------------------------------------------------- /test/base_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__)+'/test_helper' 2 | 3 | Expectations do 4 | expect nil do 5 | SignupPresenter.new.id 6 | end 7 | 8 | expect true do 9 | SignupPresenter.new.new_record? 10 | end 11 | 12 | expect :user => User, :account => Account do 13 | SignupPresenter.presented 14 | end 15 | 16 | expect User.create!(hash_for_user) do |u| 17 | SignupPresenter.new(:user => u.expected).user 18 | end 19 | 20 | expect User do 21 | SignupPresenter.new.user 22 | end 23 | 24 | expect User.any_instance.to.receive(:login=).with('james') do 25 | SignupPresenter.new(:user_login => 'james') 26 | end 27 | 28 | # admin= should be protected from mass assignment 29 | expect SignupPresenter.new.to.be.attribute_protected?(:user_admin) 30 | expect SignupPresenter.new(:user_admin => true).user.not.to.be.admin? 31 | 32 | expect 'mymockvalue' do 33 | User.any_instance.stubs(:login).returns('mymockvalue') 34 | SignupPresenter.new.user_login 35 | end 36 | 37 | expect User.any_instance.to.receive(:login=).with('mymockvalue') do 38 | SignupPresenter.new.user_login = 'mymockvalue' 39 | end 40 | 41 | expect SignupPresenter.new.not.to.be.valid? 42 | expect SignupPresenter.new(:user => User.new(hash_for_user)).to.be.valid? 43 | 44 | expect ActiveRecord::Errors do 45 | s = SignupPresenter.new 46 | s.valid? 47 | s.errors 48 | end 49 | 50 | expect ActiveRecord::Errors do 51 | s = SignupPresenter.new 52 | s.valid? 53 | s.user_errors 54 | end 55 | 56 | expect ActiveRecord::Errors do 57 | s = SignupPresenter.new 58 | s.valid? 59 | s.account_errors 60 | end 61 | 62 | expect String do 63 | s = SignupPresenter.new 64 | s.valid? 65 | s.errors.on(:user_login) 66 | end 67 | 68 | expect "can't be blank" do 69 | s = SignupPresenter.new 70 | s.valid? 71 | s.errors.on(:user_login) 72 | end 73 | 74 | expect ["User Password can't be blank"] do 75 | s = SignupPresenter.new(:user_login => 'login') 76 | s.valid? 77 | s.errors.full_messages 78 | end 79 | 80 | expect 'c4N n07 83 8L4nK' do 81 | old_locale = I18n.locale 82 | I18n.locale = '1337' 83 | 84 | s = SignupPresenter.new(:user_login => nil) 85 | s.valid? 86 | message = s.errors.on(:user_login) 87 | 88 | I18n.locale = old_locale 89 | 90 | message 91 | end 92 | 93 | expect ['U53R pa22w0rD c4N n07 83 8L4nK'] do 94 | old_locale = I18n.locale 95 | I18n.locale = '1337' 96 | 97 | s = SignupPresenter.new(:user_login => 'login') 98 | s.valid? 99 | message = s.errors.full_messages 100 | 101 | I18n.locale = old_locale 102 | 103 | message 104 | end 105 | 106 | expect ActiveRecord::Base.to.receive(:transaction) do 107 | s = SignupPresenter.new 108 | s.save 109 | end 110 | 111 | expect User.any_instance.to.receive(:save) do 112 | s = SignupPresenter.new :user => User.new(hash_for_user) 113 | s.save 114 | end 115 | 116 | expect Account.any_instance.to.receive(:save) do 117 | s = SignupPresenter.new :user => User.new(hash_for_user) 118 | s.save 119 | end 120 | 121 | expect SignupPresenter.new.not.to.be.save 122 | 123 | expect ActiveRecord::Rollback do 124 | ActiveRecord::Base.stubs(:transaction).yields 125 | User.any_instance.stubs(:save).returns(false) 126 | Account.any_instance.stubs(:save).returns(false) 127 | s = SignupPresenter.new :user => User.new(hash_for_user) 128 | s.save 129 | end 130 | 131 | expect ActiveRecord::Base.to.receive(:transaction) do 132 | s = SignupPresenter.new(:user_login => "da", :user_password => "seekrit") 133 | s.save! 134 | end 135 | 136 | expect User.any_instance.to.receive(:save!) do 137 | s = SignupPresenter.new(:user_login => "da", :user_password => "seekrit") 138 | s.save! 139 | end 140 | 141 | expect Account.any_instance.to.receive(:save!) do 142 | User.any_instance.stubs(:save!) 143 | s = SignupPresenter.new(:user_login => "da", :user_password => "seekrit") 144 | s.save! 145 | end 146 | 147 | expect ActiveRecord::RecordInvalid do 148 | SignupPresenter.new.save! 149 | end 150 | 151 | expect SignupPresenter.new(:user => User.new(hash_for_user)).to.be.save! 152 | 153 | expect SignupPresenter.new.to.be.respond_to?(:user_login) 154 | expect SignupPresenter.new.to.be.respond_to?(:user_password_confirmation) 155 | expect SignupPresenter.new.to.be.respond_to?(:valid?) # just making sure i didn't break everything :) 156 | expect SignupPresenter.new.to.be.respond_to?(:nil?, false) # making sure it's possible to pass 2 arguments 157 | 158 | expect User.create!(hash_for_user).not.to.be.login_changed? do |user| 159 | s = SignupPresenter.new(:user => user) 160 | s.update_attributes :user_login => 'Something Totally Different' 161 | end 162 | 163 | expect SignupPresenter.new(:user => User.create!(hash_for_user)).to.receive(:save) do |s| 164 | s.update_attributes :user_login => 'Something' 165 | end 166 | 167 | expect 'Something Different' do 168 | s = SignupPresenter.new 169 | s.update_attributes :user_login => 'Something Different' 170 | s.user_login 171 | end 172 | 173 | # Multiparameter assignment 174 | expect Time.parse('March 27 1980 9:30:59 am') do 175 | s = SignupPresenter.new 176 | s.update_attributes({ 177 | :"user_birthday(1i)" => '1980', 178 | :"user_birthday(2i)" => '3', 179 | :"user_birthday(3i)" => '27', 180 | :"user_birthday(4i)" => '9', 181 | :"user_birthday(5i)" => '30', 182 | :"user_birthday(6i)" => '59' 183 | }) 184 | s.user_birthday 185 | end 186 | 187 | expect nil do 188 | s = SignupPresenter.new 189 | s.attributes = nil 190 | end 191 | 192 | # this is a regression test to make sure that _title is working. we had a weird conflict with using String#delete 193 | expect 'something' do 194 | s = SignupPresenter.new :account_title => 'something' 195 | s.account_title 196 | end 197 | 198 | expect String do 199 | s = SignupPresenter.new 200 | s.save 201 | s.errors.on(:user_login) 202 | end 203 | 204 | expect String do 205 | s = SignupPresenter.new 206 | s.save! rescue 207 | s.errors.on(:user_login) 208 | end 209 | 210 | expect 'Login' do 211 | SignupPresenter.human_attribute_name(:user_login) 212 | end 213 | 214 | # it was raising with nil 215 | expect SignupPresenter do 216 | SignupPresenter.new(nil) 217 | end 218 | 219 | expect EndingWithSPresenter.new.address.not.to.be.nil? 220 | 221 | # this should act as ActiveRecord models do 222 | expect NoMethodError do 223 | SignupPresenter.new({:i_dont_exist=>"blah"}) 224 | end 225 | 226 | # ActiveRecord::Base uses nil id to signify an unsaved model 227 | expect nil do 228 | SignupPresenter.new.id 229 | end 230 | 231 | expect nil do 232 | returning(SignupPresenter.new(:user => User.new(hash_for_user))) do |presenter| 233 | presenter.save! 234 | end.id 235 | end 236 | 237 | expect CantSavePresenter.new.not.to.be.save # it won't save because the filter chain will halt 238 | 239 | expect ActiveRecord::RecordNotSaved do 240 | CantSavePresenter.new.save! 241 | end 242 | 243 | expect 'Some Street' do 244 | p = AfterSavePresenter.new 245 | p.save 246 | p.address.street 247 | end 248 | 249 | expect 'Some Street' do 250 | p = AfterSavePresenter.new 251 | p.save! 252 | p.address.street 253 | end 254 | 255 | expect SamePrefixPresenter.new.to.be.respond_to?(:account_title) 256 | expect SamePrefixPresenter.new.to.be.respond_to?(:account_info_info) 257 | 258 | expect [:before_validation, :before_save, :after_save] do 259 | returning(CallbackOrderingPresenter.new) do |presenter| 260 | presenter.save! 261 | end.steps 262 | end 263 | 264 | expect [:before_validation, :before_save] do 265 | returning(CallbackCantSavePresenter.new) do |presenter| 266 | presenter.save 267 | end.steps 268 | end 269 | 270 | expect [:before_validation, :before_save] do 271 | returning(CallbackCantSavePresenter.new) do |presenter| 272 | begin 273 | presenter.save! 274 | rescue ActiveRecord::RecordNotSaved 275 | # NOP 276 | end 277 | end.steps 278 | end 279 | 280 | expect ActiveRecord::RecordNotSaved do 281 | CallbackCantSavePresenter.new.save! 282 | end 283 | 284 | expect ActiveRecord::RecordInvalid do 285 | CallbackCantValidatePresenter.new.save! 286 | end 287 | 288 | expect [:before_validation] do 289 | returning(CallbackCantValidatePresenter.new) do |presenter| 290 | begin 291 | presenter.save! 292 | rescue ActiveRecord::RecordInvalid 293 | # NOP 294 | end 295 | end.steps 296 | end 297 | 298 | expect [:before_validation] do 299 | returning(CallbackCantValidatePresenter.new) do |presenter| 300 | presenter.save 301 | end.steps 302 | end 303 | 304 | expect ActiveRecord::Errors.any_instance.to.receive(:clear).twice do 305 | CallbackCantValidatePresenter.new.valid? 306 | end 307 | 308 | # this should act as ActiveRecord models do 309 | expect NoMethodError do 310 | SignupPresenter.new({:i_dont_exist=>"blah"}) 311 | end 312 | 313 | expect false do 314 | SignupNoNilPresenter.new.save 315 | end 316 | 317 | expect true do 318 | SignupNoNilPresenter.new(:user => nil, :account => Account.new).save 319 | end 320 | 321 | expect true do 322 | SignupNoNilPresenter.new(:user => nil, :account => Account.new).save! 323 | end 324 | 325 | expect Address do 326 | PresenterWithTwoAddresses.new.secondary_address 327 | end 328 | 329 | expect "123 awesome st" do 330 | p = PresenterWithTwoAddresses.new(:secondary_address_street => "123 awesome st") 331 | p.save 332 | p.secondary_address_street 333 | end 334 | 335 | # attr_protected 336 | expect "" do 337 | p = SignupPresenter.new(:account_secret => 'swordfish') 338 | p.account.secret 339 | end 340 | 341 | expect "comment" do 342 | p = HistoricalPresenter.new(:history_comment => 'comment', :user => User.new(hash_for_user)) 343 | p.save 344 | p.history_comment 345 | end 346 | 347 | expect false do 348 | SignupPresenter.new.changed? 349 | end 350 | 351 | expect true do 352 | p = SignupPresenter.new(:user => User.new(hash_for_user)) 353 | p.save 354 | p.user_login = 'something_else' 355 | p.changed? 356 | end 357 | 358 | end 359 | -------------------------------------------------------------------------------- /lib/active_presenter/base.rb: -------------------------------------------------------------------------------- 1 | module ActivePresenter 2 | # Base class for presenters. See README for usage. 3 | # 4 | class Base 5 | include ActiveSupport::Callbacks 6 | define_callbacks :before_validation, :before_save, :after_save 7 | 8 | class_inheritable_accessor :presented 9 | class_inheritable_accessor :attr_protected, :attr_accessible 10 | self.presented = {} 11 | 12 | # Indicates which models are to be presented by this presenter. 13 | # i.e. 14 | # 15 | # class SignupPresenter < ActivePresenter::Base 16 | # presents :user, :account 17 | # end 18 | # 19 | # 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: 20 | # 21 | # class PresenterWithTwoAddresses < ActivePresenter::Base 22 | # presents :primary_address => Address, :secondary_address => Address 23 | # end 24 | # 25 | def self.presents(*types) 26 | types_and_classes = types.extract_options! 27 | types.each { |t| types_and_classes[t] = t.to_s.tableize.classify.constantize } 28 | 29 | attr_accessor *types_and_classes.keys 30 | 31 | types_and_classes.keys.each do |t| 32 | define_method("#{t}_errors") do 33 | send(t).errors 34 | end 35 | 36 | presented[t] = types_and_classes[t] 37 | end 38 | end 39 | 40 | def self.human_attribute_name(attribute_key_name, options = {}) 41 | presentable_type = presented.keys.detect do |type| 42 | attribute_key_name.to_s.starts_with?("#{type}_") || attribute_key_name.to_s == type.to_s 43 | end 44 | attribute_key_name_without_class = attribute_key_name.to_s.gsub("#{presentable_type}_", "") 45 | 46 | if presented[presentable_type] and attribute_key_name_without_class != presentable_type.to_s 47 | presented[presentable_type].human_attribute_name(attribute_key_name_without_class, options) 48 | else 49 | I18n.translate(presentable_type, options.merge(:default => presentable_type.to_s.humanize, :scope => [:activerecord, :models])) 50 | end 51 | end 52 | 53 | # Since ActivePresenter does not descend from ActiveRecord, we need to 54 | # mimic some ActiveRecord behavior in order for the ActiveRecord::Errors 55 | # object we're using to work properly. 56 | # 57 | # This problem was introduced with Rails 2.3.4. 58 | # Fix courtesy http://gist.github.com/191263 59 | def self.self_and_descendants_from_active_record # :nodoc: 60 | [self] 61 | end 62 | 63 | def self.human_name(options = {}) # :nodoc: 64 | defaults = self_and_descendants_from_active_record.map do |klass| 65 | :"#{klass.name.underscore}" 66 | end 67 | defaults << self.name.humanize 68 | I18n.translate(defaults.shift, {:scope => [:activerecord, :models], :count => 1, :default => defaults}.merge(options)) 69 | end 70 | 71 | # Note that +attr_protected+ is still applied to the received hash. Thus, 72 | # with this technique you can at most _extend_ the list of protected 73 | # attributes for a particular mass-assignment call. 74 | def self.attr_protected(*attributes) 75 | write_inheritable_attribute(:attr_protected, Set.new(attributes.map {|a| a.to_s}) + (protected_attributes || [])) 76 | end 77 | 78 | # Returns an array of all the attributes that have been protected from mass-assignment. 79 | def self.protected_attributes # :nodoc: 80 | read_inheritable_attribute(:attr_protected) 81 | end 82 | 83 | # Note that +attr_accessible+ is still applied to the received hash. Thus, 84 | # with this technique you can at most _narrow_ the list of accessible 85 | # attributes for a particular mass-assignment call. 86 | def self.attr_accessible(*attributes) 87 | write_inheritable_attribute(:attr_accessible, Set.new(attributes.map(&:to_s)) + (accessible_attributes || [])) 88 | end 89 | 90 | # Returns an array of all the attributes that have been made accessible to mass-assignment. 91 | def self.accessible_attributes # :nodoc: 92 | read_inheritable_attribute(:attr_accessible) 93 | end 94 | 95 | # 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: 96 | # 97 | # 1. SignupPresenter.new(:user_login => 'james', :user_password => 'swordfish', :user_password_confirmation => 'swordfish', :account_subdomain => 'giraffesoft') 98 | # - This form is useful for initializing a new presenter from the params hash: i.e. SignupPresenter.new(params[:signup_presenter]) 99 | # 2. SignupPresenter.new(:user => User.find(1), :account => Account.find(2)) 100 | # - 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. 101 | # 102 | # Both forms can also be mixed together: SignupPresenter.new(:user => User.find(1), :user_login => 'james') 103 | # In this case, the login attribute will be updated on the user instance provided. 104 | # 105 | # If you don't specify an instance, one will be created by calling Model.new 106 | # 107 | def initialize(args = {}) 108 | args ||= {} 109 | 110 | presented.each do |type, klass| 111 | send("#{type}=", args[type].is_a?(klass) ? args.delete(type) : klass.new(args.delete(type))) 112 | end 113 | 114 | self.attributes = args 115 | end 116 | 117 | # Set the attributes of the presentable instances using 118 | # the type_attribute form (i.e. user_login => 'james'), or 119 | # the multiparameter attribute form (i.e. {user_birthday(1i) => "1980", user_birthday(2i) => "3"}) 120 | # 121 | def attributes=(attrs) 122 | return if attrs.nil? 123 | 124 | attrs = attrs.stringify_keys 125 | multi_parameter_attributes = {} 126 | attrs = remove_attributes_protected_from_mass_assignment(attrs) 127 | 128 | attrs.each do |k,v| 129 | if (base_attribute = k.to_s.split("(").first) != k.to_s 130 | presentable = presentable_for(base_attribute) 131 | multi_parameter_attributes[presentable] ||= {} 132 | multi_parameter_attributes[presentable].merge!(flatten_attribute_name(k,presentable).to_sym => v) 133 | else 134 | send("#{k}=", v) unless attribute_protected?(k) 135 | end 136 | end 137 | 138 | multi_parameter_attributes.each do |presentable,multi_attrs| 139 | send(presentable).send(:attributes=, multi_attrs) 140 | end 141 | end 142 | 143 | # Makes sure that the presenter is accurate about responding to presentable's attributes, even though they are handled by method_missing. 144 | # 145 | def respond_to?(method, include_private = false) 146 | presented_attribute?(method) || super 147 | end 148 | 149 | # Handles the decision about whether to delegate getters and setters to presentable instances. 150 | # 151 | def method_missing(method_name, *args, &block) 152 | presented_attribute?(method_name) ? delegate_message(method_name, *args, &block) : super 153 | end 154 | 155 | # Returns an instance of ActiveRecord::Errors with all the errors from the presentables merged in using the type_attribute form (i.e. user_login). 156 | # 157 | def errors 158 | @errors ||= ActiveRecord::Errors.new(self) 159 | end 160 | 161 | # Returns boolean based on the validity of the presentables by calling valid? on each of them. 162 | # 163 | def valid? 164 | errors.clear 165 | if run_callbacks_with_halt(:before_validation) 166 | presented.keys.each do |type| 167 | presented_inst = send(type) 168 | 169 | next unless save?(type, presented_inst) 170 | merge_errors(presented_inst, type) unless presented_inst.valid? 171 | end 172 | 173 | errors.empty? 174 | end 175 | end 176 | 177 | # Do any of the attributes have unsaved changes? 178 | def changed? 179 | presented_instances.map(&:changed?).any? 180 | end 181 | 182 | # Save all of the presentables, wrapped in a transaction. 183 | # 184 | # Returns true or false based on success. 185 | # 186 | def save 187 | saved = false 188 | 189 | ActiveRecord::Base.transaction do 190 | if valid? && run_callbacks_with_halt(:before_save) 191 | saved = presented.keys.select {|key| save?(key, send(key))}.all? {|key| send(key).save} 192 | raise ActiveRecord::Rollback unless saved # TODO: Does this happen implicitly? 193 | end 194 | 195 | run_callbacks_with_halt(:after_save) if saved 196 | end 197 | 198 | saved 199 | end 200 | 201 | # Save all of the presentables wrapped in a transaction. 202 | # 203 | # Returns true on success, will raise otherwise. 204 | # 205 | def save! 206 | raise ActiveRecord::RecordInvalid.new(self) unless valid? 207 | raise ActiveRecord::RecordNotSaved unless run_callbacks_with_halt(:before_save) 208 | 209 | ActiveRecord::Base.transaction do 210 | presented.keys.select {|key| save?(key, send(key))}.each {|key| send(key).save!} 211 | 212 | run_callbacks_with_halt(:after_save) 213 | end 214 | 215 | true 216 | end 217 | 218 | def self.create(attrs={}) 219 | new(attrs).save 220 | end 221 | 222 | def self.create!(attrs={}) 223 | new(attrs).save! 224 | end 225 | 226 | # Update attributes, and save the presentables 227 | # 228 | # Returns true or false based on success. 229 | # 230 | def update_attributes(attrs) 231 | self.attributes = attrs 232 | save 233 | end 234 | 235 | # Should this presented instance be saved? By default, this returns true 236 | # Called from #save and #save! 237 | # 238 | # For 239 | # class SignupPresenter < ActivePresenter::Base 240 | # presents :account, :user 241 | # end 242 | # 243 | # #save? will be called twice: 244 | # save?(:account, #) 245 | # save?(:user, #) 246 | def save?(presented_key, presented_instance) 247 | true 248 | end 249 | 250 | # We define #id and #new_record? to play nice with form_for(@presenter) in Rails 251 | def id # :nodoc: 252 | nil 253 | end 254 | 255 | def new_record? 256 | true 257 | end 258 | 259 | protected 260 | def presented_instances 261 | presented.keys.map { |key| send(key) } 262 | end 263 | 264 | def delegate_message(method_name, *args, &block) 265 | presentable = presentable_for(method_name) 266 | send(presentable).send(flatten_attribute_name(method_name, presentable), *args, &block) 267 | end 268 | 269 | def presentable_for(method_name) 270 | presented.keys.sort_by { |k| k.to_s.size }.reverse.detect do |type| 271 | method_name.to_s.starts_with?(attribute_prefix(type)) 272 | end 273 | end 274 | 275 | def presented_attribute?(method_name) 276 | p = presentable_for(method_name) 277 | !p.nil? && send(p).respond_to?(flatten_attribute_name(method_name,p)) 278 | end 279 | 280 | def flatten_attribute_name(name, type) 281 | name.to_s.gsub(/^#{attribute_prefix(type)}/, '') 282 | end 283 | 284 | def attribute_prefix(type) 285 | "#{type}_" 286 | end 287 | 288 | def merge_errors(presented_inst, type) 289 | presented_inst.errors.each do |att,msg| 290 | if att == 'base' 291 | errors.add(type, msg) 292 | else 293 | errors.add(attribute_prefix(type)+att, msg) 294 | end 295 | end 296 | end 297 | 298 | def attribute_protected?(name) 299 | presentable = presentable_for(name) 300 | return false unless presentable 301 | flat_attribute = {flatten_attribute_name(name, presentable) => ''} #remove_att... normally takes a hash, so we use a '' 302 | presented[presentable].new.send(:remove_attributes_protected_from_mass_assignment, flat_attribute).empty? 303 | end 304 | 305 | def run_callbacks_with_halt(callback) 306 | run_callbacks(callback) { |result, object| result == false } 307 | end 308 | 309 | def remove_attributes_protected_from_mass_assignment(attributes) 310 | if self.class.accessible_attributes.nil? && self.class.protected_attributes.nil? 311 | attributes 312 | elsif self.class.protected_attributes.nil? 313 | attributes.reject { |key, value| !self.class.accessible_attributes.include?(key.gsub(/\(.+/, ""))} 314 | elsif self.class.accessible_attributes.nil? 315 | attributes.reject { |key, value| self.class.protected_attributes.include?(key.gsub(/\(.+/,""))} 316 | else 317 | raise "Declare either attr_protected or attr_accessible for #{self.class}, but not both." 318 | end 319 | end 320 | 321 | end 322 | end 323 | --------------------------------------------------------------------------------