├── test ├── rails_app │ ├── public │ │ ├── favicon.ico │ │ └── images │ │ │ └── cover.png │ ├── app │ │ ├── views │ │ │ ├── books │ │ │ │ ├── purchase.html.erb │ │ │ │ ├── error.html.erb │ │ │ │ ├── errata.html.erb │ │ │ │ ├── errata2.html.erb │ │ │ │ ├── _book.html.erb │ │ │ │ ├── _book_locals.html.erb │ │ │ │ ├── _book.json.jbuilder │ │ │ │ ├── index.html.erb │ │ │ │ └── show.html.erb │ │ │ ├── authors │ │ │ │ ├── _author.html.erb │ │ │ │ ├── _author_locals.html.erb │ │ │ │ ├── show.json.jbuilder │ │ │ │ ├── index.html.erb │ │ │ │ └── show.html.erb │ │ │ ├── book_mailer │ │ │ │ └── thanks.text.erb │ │ │ └── movies │ │ │ │ └── show.html.erb │ │ └── decorators │ │ │ └── comic_decorator.rb │ └── app.rb ├── controllers │ └── fake_detection_test.rb ├── features │ ├── jbuilder_test.rb │ ├── action_controller_api_test.rb │ ├── partial_test.rb │ ├── name_error_handling_test.rb │ ├── action_view_helpers_test.rb │ ├── controller_ivar_test.rb │ └── association_test.rb ├── generators │ └── rspec │ │ └── decorator_generator_test.rb ├── configuration_test.rb ├── models │ └── association_test.rb ├── test_helper.rb └── decorator_test.rb ├── .gitignore ├── lib ├── generators │ ├── rails │ │ ├── templates │ │ │ └── decorator.rb │ │ ├── USAGE │ │ └── decorator_generator.rb │ ├── test_unit │ │ ├── templates │ │ │ └── decorator_test.rb │ │ └── decorator_generator.rb │ └── rspec │ │ ├── templates │ │ └── decorator_spec.rb │ │ └── decorator_generator.rb ├── active_decorator │ ├── version.rb │ ├── decorated.rb │ ├── config.rb │ ├── monkey │ │ ├── action_view │ │ │ ├── object_renderer.rb │ │ │ ├── collection_renderer.rb │ │ │ └── partial_renderer.rb │ │ ├── abstract_controller │ │ │ └── rendering.rb │ │ ├── action_controller │ │ │ └── base │ │ │ │ └── rescue_from.rb │ │ └── active_record │ │ │ └── associations.rb │ ├── helpers.rb │ ├── view_context.rb │ ├── railtie.rb │ └── decorator.rb └── active_decorator.rb ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── Rakefile ├── MIT-LICENSE ├── active_decorator.gemspec ├── Gemfile ├── CHANGELOG.md └── README.md /test/rails_app/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/purchase.html.erb: -------------------------------------------------------------------------------- 1 | done 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/error.html.erb: -------------------------------------------------------------------------------- 1 | <%= @book.error %> 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/errata.html.erb: -------------------------------------------------------------------------------- 1 | <%= @book.errata %> 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/errata2.html.erb: -------------------------------------------------------------------------------- 1 | <%= @book.errata2 %> 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/_book.html.erb: -------------------------------------------------------------------------------- 1 | <%= book.title %> 2 | <%= book.reverse_title %> 3 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/_book_locals.html.erb: -------------------------------------------------------------------------------- 1 | <%= b.title %> 2 | <%= b.upcased_title %> 3 | -------------------------------------------------------------------------------- /test/rails_app/app/views/authors/_author.html.erb: -------------------------------------------------------------------------------- 1 | <%= author.name %> 2 | <%= author.reverse_name %> 3 | -------------------------------------------------------------------------------- /test/rails_app/app/views/authors/_author_locals.html.erb: -------------------------------------------------------------------------------- 1 | <%= a.name %> 2 | <%= a.reverse_name %> 3 | -------------------------------------------------------------------------------- /test/rails_app/app/views/book_mailer/thanks.text.erb: -------------------------------------------------------------------------------- 1 | Thank you for purchasing <%= @book.upcased_title %>. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | gemfiles/*.lock 5 | pkg/* 6 | log 7 | tmp 8 | .byebug_history 9 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module <%= class_name %>Decorator 4 | end 5 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/_book.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.title book.title 2 | json.reverse_title book.reverse_title 3 | -------------------------------------------------------------------------------- /lib/active_decorator/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDecorator 4 | VERSION = '1.5.1' 5 | end 6 | -------------------------------------------------------------------------------- /test/rails_app/app/views/movies/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= @movie.name %> 2 | <% if a = @movie.author %><%= a.reverse_name %><% end %> 3 | -------------------------------------------------------------------------------- /test/rails_app/public/images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/active_decorator/HEAD/test/rails_app/public/images/cover.png -------------------------------------------------------------------------------- /test/rails_app/app/views/authors/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.name @author.name 2 | json.books @author.books, partial: 'books/book', as: :book 3 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/index.html.erb: -------------------------------------------------------------------------------- 1 | <% @books.each do |book| %> 2 | <%= book.title %> 3 | <%= book.reverse_title %> 4 | <% end %> 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /test/rails_app/app/views/authors/index.html.erb: -------------------------------------------------------------------------------- 1 | <% @authors.each do |author| %> 2 | <%= author.name %> 3 | <%= author.reverse_name %> 4 | <% end %> 5 | -------------------------------------------------------------------------------- /test/rails_app/app/views/books/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= @book.link %> 2 | <%= @book.cover_image %> 3 | 4 | <%= link_to 'purchase', [:purchase, @book.author, @book], method: :post %> 5 | -------------------------------------------------------------------------------- /lib/active_decorator/decorated.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A no-op module to mark an AR model instance as a "decorated" object. 4 | module ActiveDecorator::Decorated 5 | end 6 | -------------------------------------------------------------------------------- /test/rails_app/app/decorators/comic_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # decorator to test auto-loading behavior 4 | # this module is intended not to be loaded. 5 | module ComicDecorator; end 6 | -------------------------------------------------------------------------------- /lib/generators/rails/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Stubs out a decorator module in app/decorators directory. 3 | 4 | Examples: 5 | `rails g decorator book` 6 | 7 | This creates: 8 | app/decorators/book_decorator.rb 9 | -------------------------------------------------------------------------------- /lib/active_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_decorator/version' 4 | require 'active_decorator/decorator' 5 | begin 6 | require 'rails' 7 | require 'active_decorator/railtie' 8 | rescue LoadError 9 | end 10 | require 'active_decorator/config' 11 | -------------------------------------------------------------------------------- /lib/active_decorator/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDecorator 4 | def self.config 5 | @_config ||= Struct.new(:decorator_suffix).new 6 | end 7 | 8 | def self.configure 9 | yield config 10 | end 11 | 12 | config.decorator_suffix = 'Decorator' 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_decorator/monkey/action_view/object_renderer.rb: -------------------------------------------------------------------------------- 1 | module ActiveDecorator 2 | module Monkey 3 | module ActionView 4 | module ObjectRenderer 5 | def render_object_with_partial(object, *) 6 | ActiveDecorator::Decorator.instance.decorate object 7 | super 8 | end 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/generators/test_unit/templates/decorator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class <%= class_name %>DecoratorTest < ActiveSupport::TestCase 6 | def setup 7 | @<%= singular_name %> = <%= class_name %>.new.extend <%= class_name %>Decorator 8 | end 9 | 10 | # test "the truth" do 11 | # assert true 12 | # end 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_decorator/monkey/action_view/collection_renderer.rb: -------------------------------------------------------------------------------- 1 | module ActiveDecorator 2 | module Monkey 3 | module ActionView 4 | module CollectionRenderer 5 | def render_collection_with_partial(collection, *) 6 | ActiveDecorator::Decorator.instance.decorate collection 7 | super 8 | end 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/generators/rspec/templates/decorator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require '<%= File.exist?('spec/rails_helper.rb') ? 'rails_helper' : 'spec_helper' %>' 4 | 5 | RSpec.describe <%= class_name %>Decorator do 6 | let(:<%= singular_name %>) { <%= class_name %>.new.extend <%= class_name %>Decorator } 7 | subject { <%= singular_name %> } 8 | it { should be_a <%= class_name %> } 9 | end 10 | -------------------------------------------------------------------------------- /test/controllers/fake_detection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class MoviesControllerTest < ActionController::TestCase 6 | test 'reveals fakes' do 7 | movie = Movie.create 8 | if Rails::VERSION::MAJOR >= 5 9 | assert_nothing_raised { get :show, params: {id: movie.id} } 10 | else 11 | assert_nothing_raised { get :show, id: movie.id } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/rspec/decorator_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rspec 4 | module Generators 5 | class DecoratorGenerator < ::Rails::Generators::NamedBase 6 | source_root File.expand_path("templates", __dir__) 7 | 8 | def create_spec_file 9 | template 'decorator_spec.rb', File.join('spec/decorators', class_path, "#{file_name}_decorator_spec.rb") 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # frozen_string_literal: true 3 | 4 | require 'bundler' 5 | Bundler::GemHelper.install_tasks 6 | 7 | require 'rake/testtask' 8 | 9 | Rake::TestTask.new do |t| 10 | t.libs << "test" 11 | if ENV['API'] == '1' 12 | t.pattern = 'test/**/*_api_test.rb' 13 | else 14 | t.test_files = Dir['test/**/*_test.rb'] - Dir['test/**/*_api_test.rb'] 15 | end 16 | t.warning = true 17 | t.verbose = true 18 | end 19 | 20 | task default: :test 21 | -------------------------------------------------------------------------------- /lib/generators/test_unit/decorator_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TestUnit 4 | module Generators 5 | class DecoratorGenerator < ::Rails::Generators::NamedBase 6 | source_root File.expand_path("templates", __dir__) 7 | check_class_collision suffix: "DecoratorTest" 8 | 9 | def create_test_file 10 | template 'decorator_test.rb', File.join('test/decorators', class_path, "#{file_name}_decorator_test.rb") 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/rails/decorator_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rails 4 | module Generators 5 | class DecoratorGenerator < NamedBase 6 | source_root File.expand_path("templates", __dir__) 7 | check_class_collision suffix: "Decorator" 8 | 9 | def create_decorator_file 10 | template 'decorator.rb', File.join('app/decorators', class_path, "#{file_name}_decorator.rb") 11 | end 12 | 13 | hook_for :test_framework 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/features/jbuilder_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class JbuilderTest < ActionDispatch::IntegrationTest 6 | setup do 7 | Author.create! name: 'aamine' 8 | nari = Author.create! name: 'nari' 9 | nari.books.create! title: 'the gc book' 10 | end 11 | 12 | test 'decorating objects in Jbuilder partials' do 13 | visit "/authors/#{Author.last.id}.json" 14 | assert_equal '{"name":"nari","books":[{"title":"the gc book","reverse_title":"koob cg eht"}]}', page.source 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/generators/rspec/decorator_generator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | require 'rails/generators' 5 | require 'generators/rspec/decorator_generator' 6 | 7 | class DecoratorGeneratorTest < Rails::Generators::TestCase 8 | tests Rspec::Generators::DecoratorGenerator 9 | destination Rails.root.join('tmp/generators') 10 | setup :prepare_destination 11 | 12 | test 'generator runs without errors' do 13 | assert_nothing_raised do 14 | run_generator ['decorator'] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_decorator/monkey/abstract_controller/rendering.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A monkey-patch for Rails controllers/mailers that auto-decorates ivars 4 | # that are passed to views. 5 | module ActiveDecorator 6 | module Monkey 7 | module AbstractController 8 | module Rendering 9 | def view_assigns 10 | hash = super 11 | hash.each_value do |v| 12 | ActiveDecorator::Decorator.instance.decorate v 13 | end 14 | hash 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/rails_app/app/views/authors/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= @author.name %> 2 | <%= @author.capitalized_name %> 3 | <%= @author.books.first.upcased_title %> 4 | <%= @author.books.last.upcased_title %> 5 | <% if p = @author.publishers.take %><%= p.upcased_name %><% end %> 6 | <% if Rails.version.to_f >= 5.1 && p = @author.publishers.second_to_last %><%= p.reversed_name %><% end %> 7 | <% if p = @author.profile %><%= p.address %><% end %> 8 | <% if h = @author.profile_history %><%= h.update_date %><% end %> 9 | <% if m = @author.magazines.first %><%= m.upcased_title %><% end %> 10 | <% if c = @author.company %><%= c.reverse_name %><% end %> 11 | -------------------------------------------------------------------------------- /lib/active_decorator/monkey/action_controller/base/rescue_from.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A monkey-patch for Action Controller to pass the controller view_context over 4 | # to `render` invocation in `rescue_from` block. 5 | module ActiveDecorator 6 | module Monkey 7 | module ActionController 8 | module Base 9 | def rescue_with_handler(*) 10 | ActiveDecorator::ViewContext.push(view_context) if defined?(view_context) 11 | super 12 | ensure 13 | ActiveDecorator::ViewContext.pop if defined?(view_context) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/features/action_controller_api_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'test_helper' 3 | 4 | if Rails::VERSION::MAJOR >= 5 5 | 6 | class ActionControllerAPITest < ActionDispatch::IntegrationTest 7 | setup do 8 | Bookstore.create! name: 'junkudo' 9 | end 10 | 11 | test 'decorating objects in api only controllers' do 12 | get "/api/bookstores/#{Bookstore.last.id}.json" 13 | 14 | assert_equal({"initial" => "j", "name" => "junkudo"}, response.parsed_body) 15 | end 16 | 17 | test 'error handling in api only controllers' do 18 | get "/api/bookstores/error.json" 19 | 20 | assert_response :error 21 | end 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /test/configuration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | Comic = Struct.new(:title, :price) 6 | 7 | module ComicPresenter 8 | def price 9 | "$#{super}" 10 | end 11 | end 12 | 13 | class ConfigurationTest < Test::Unit::TestCase 14 | test 'with a custom decorator_suffix' do 15 | begin 16 | ActiveDecorator.configure do |config| 17 | config.decorator_suffix = 'Presenter' 18 | end 19 | 20 | comic = ActiveDecorator::Decorator.instance.decorate Comic.new("amatsuda's (Poignant) Guide to ActiveDecorator", 3) 21 | assert_equal '$3', comic.price 22 | ensure 23 | ActiveDecorator.configure do |config| 24 | config.decorator_suffix = 'Decorator' 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/features/partial_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class PartialTest < ActionDispatch::IntegrationTest 6 | setup do 7 | Author.create! name: 'aamine' 8 | end 9 | 10 | test 'decorating implicit @object' do 11 | visit '/authors/partial' 12 | assert page.has_content? 'aamine' 13 | assert page.has_content? 'aamine'.reverse 14 | end 15 | 16 | test 'decorating implicit @collection' do 17 | visit '/authors/partial?pattern=collection' 18 | assert page.has_content? 'aamine' 19 | assert page.has_content? 'aamine'.reverse 20 | end 21 | 22 | test 'decorating objects in @locals' do 23 | visit '/authors/partial?pattern=locals' 24 | assert page.has_content? 'aamine' 25 | assert page.has_content? 'aamine'.reverse 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/active_decorator/monkey/action_view/partial_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A monkey-patch for Action View `render :partial` that auto-decorates `locals` values. 4 | module ActiveDecorator 5 | module Monkey 6 | module ActionView 7 | module PartialRenderer 8 | if Rails.version.to_f >= 6.1 9 | def initialize(*) 10 | super 11 | 12 | @locals.each_value do |v| 13 | ActiveDecorator::Decorator.instance.decorate v 14 | end 15 | end 16 | end 17 | 18 | private 19 | 20 | if Rails.version.to_f < 6.1 21 | def setup(*) 22 | super 23 | 24 | @locals.each_value do |v| 25 | ActiveDecorator::Decorator.instance.decorate v 26 | end if @locals 27 | ActiveDecorator::Decorator.instance.decorate @object if @object 28 | ActiveDecorator::Decorator.instance.decorate @collection unless @collection.blank? 29 | 30 | self 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Akira Matsuda 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/features/name_error_handling_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class NameErrorHandlingTest < ActionDispatch::IntegrationTest 6 | setup do 7 | aamine = Author.create! name: 'aamine' 8 | @rhg = aamine.books.create! title: 'RHG' 9 | end 10 | 11 | test 'raising NameError in a decorator' do 12 | err = assert_raises ActionView::Template::Error do 13 | visit "/authors/#{@rhg.author.id}/books/#{@rhg.id}/errata" 14 | end 15 | 16 | assert_match(/undefined method `poof!' for/, err.message) 17 | assert_match 'poof!', err.cause.name if err.respond_to?(:cause) 18 | end 19 | 20 | test "Don't touch NameError that was not raised from a decorator module but from other classes" do 21 | err = assert_raises ActionView::Template::Error do 22 | visit "/authors/#{@rhg.author.id}/books/#{@rhg.id}/errata2" 23 | end 24 | 25 | assert_match(/undefined method [`']boom!' for/, err.message) 26 | assert_match 'boom!', err.cause.name if err.respond_to?(:cause) 27 | assert_not_match(/active_decorator\/lib\/active_decorator\/.* in method_missing'$/, err.backtrace[0]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /active_decorator.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $:.push File.expand_path("../lib", __FILE__) 4 | require "active_decorator/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "active_decorator" 8 | s.version = ActiveDecorator::VERSION 9 | s.authors = ["Akira Matsuda"] 10 | s.email = ["ronnie@dio.jp"] 11 | s.homepage = 'https://github.com/amatsuda/active_decorator' 12 | s.license = 'MIT' 13 | s.summary = %q{A simple and Rubyish view helper for Rails} 14 | s.description = %q{A simple and Rubyish view helper for Rails} 15 | 16 | s.files = Dir.chdir(File.expand_path('..', __FILE__)) do 17 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | end 19 | s.require_paths = ["lib"] 20 | 21 | s.add_dependency 'activesupport' 22 | 23 | s.add_development_dependency 'test-unit-rails' 24 | s.add_development_dependency 'selenium-webdriver' 25 | s.add_development_dependency 'puma' 26 | s.add_development_dependency 'capybara' 27 | s.add_development_dependency 'sqlite3' 28 | s.add_development_dependency 'rake' 29 | s.add_development_dependency 'byebug' 30 | end 31 | -------------------------------------------------------------------------------- /lib/active_decorator/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # On the fly delegation from the decorator to the decorated object and the helpers. 4 | module ActiveDecorator 5 | module Helpers 6 | def method_missing(method, *args, **kwargs, &block) 7 | super 8 | rescue NoMethodError, NameError => e1 9 | # the error is not mine, so just releases it as is. 10 | raise e1 if e1.name != method 11 | 12 | if (view_context = ActiveDecorator::ViewContext.current) 13 | begin 14 | if kwargs.any? 15 | view_context.send method, *args, **kwargs, &block 16 | else 17 | view_context.send method, *args, &block 18 | end 19 | rescue NoMethodError => e2 20 | raise e2 if e2.name != method 21 | 22 | raise NoMethodError.new("undefined method `#{method}' for either #{self} or #{view_context}", method) 23 | rescue NameError => e2 24 | raise e2 if e2.name != method 25 | 26 | raise NameError.new("undefined local variable `#{method}' for either #{self} or #{view_context}", method) 27 | end 28 | else # AC::API would have not view_context 29 | raise e1 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/features/action_view_helpers_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class ActionViewHelpersTest < ActionDispatch::IntegrationTest 6 | setup do 7 | aamine = Author.create! name: 'aamine' 8 | @rhg = aamine.books.create! title: 'RHG' 9 | @rhg_novel = aamine.books.create! title: 'RHG Novel', type: 'Novel' 10 | end 11 | 12 | test 'invoking action_view helper methods' do 13 | visit "/authors/#{@rhg.author.id}/books/#{@rhg.id}" 14 | within 'a.title' do 15 | assert page.has_content? 'RHG' 16 | end 17 | assert page.has_css?('img') 18 | end 19 | 20 | test 'invoking action_view helper methods on model subclass' do 21 | visit "/authors/#{@rhg_novel.author.id}/books/#{@rhg_novel.id}" 22 | within 'a.title' do 23 | assert page.has_content? 'RHG Novel' 24 | end 25 | assert page.has_css?('img') 26 | end 27 | 28 | test 'invoking action_view helper methods in rescue_from view' do 29 | visit "/authors/#{@rhg.author.id}/books/#{@rhg.id}/error" 30 | assert page.has_content?('ERROR') 31 | end 32 | 33 | test 'make sure that action_view + action_mailer works' do 34 | visit "/authors/#{@rhg.author.id}/books/#{@rhg.id}" 35 | click_link 'purchase' 36 | assert page.has_content? 'done' 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/features/controller_ivar_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class ControllerIvarTest < ActionDispatch::IntegrationTest 6 | setup do 7 | @matz = Author.create! name: 'matz' 8 | @matz.books.create! title: 'the world of code' 9 | Author.create! name: 'takahashim' 10 | end 11 | 12 | test 'decorating a model object in ivar' do 13 | visit "/authors/#{@matz.id}" 14 | assert page.has_content? 'matz' 15 | assert page.has_content? 'matz'.capitalize 16 | end 17 | 18 | test 'decorating model scope in ivar' do 19 | visit '/authors' 20 | assert page.has_content? 'takahashim' 21 | assert page.has_content? 'takahashim'.reverse 22 | end 23 | 24 | test "decorating models' array in ivar" do 25 | visit '/authors?variable_type=array' 26 | assert page.has_content? 'takahashim' 27 | assert page.has_content? 'takahashim'.reverse 28 | end 29 | 30 | test "decorating models' proxy object in ivar" do 31 | visit '/authors?variable_type=proxy' 32 | assert page.has_content? 'takahashim' 33 | assert page.has_content? 'takahashim'.reverse 34 | end 35 | 36 | test 'decorating model association proxy in ivar' do 37 | visit "/authors/#{@matz.id}/books" 38 | assert page.has_content? 'the world of code' 39 | assert page.has_content? 'the world of code'.reverse 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | if ENV['RAILS_VERSION'] == 'edge' 8 | gem 'rails', git: 'https://github.com/rails/rails.git' 9 | gem 'rackup' 10 | elsif ENV['RAILS_VERSION'] 11 | gem 'rails', "~> #{ENV['RAILS_VERSION']}.0" 12 | if ENV['RAILS_VERSION'] <= '5.0' 13 | gem 'sqlite3', '< 1.4' 14 | elsif (ENV['RAILS_VERSION'] <= '8') || (RUBY_VERSION < '3') 15 | gem 'sqlite3', '< 2' 16 | end 17 | gem 'rackup' if ENV['RAILS_VERSION'] > '7.1' 18 | else 19 | gem 'rails' 20 | end 21 | 22 | if RUBY_VERSION < '2.7' 23 | gem 'puma', '< 6' 24 | else 25 | gem 'puma' 26 | end 27 | 28 | gem 'nokogiri', RUBY_VERSION < '2.1' ? '~> 1.6.0' : '>= 1.7' 29 | gem 'loofah', RUBY_VERSION < '2.5' ? '< 2.21.0' : '>= 0' 30 | gem 'concurrent-ruby', RUBY_VERSION < '2.3' ? '~> 1.1.0' : '>= 1.2' 31 | if RUBY_VERSION >= '3.1' 32 | gem 'power_assert' 33 | elsif RUBY_VERSION >= '2.5' 34 | gem 'power_assert', '< 3' 35 | end 36 | gem 'selenium-webdriver', RUBY_VERSION == '3.0' ? '4.9.0' : '>= 0' 37 | gem 'webdrivers' if (ENV['RAILS_VERSION'] && ENV['RAILS_VERSION'] >= '6') && (RUBY_VERSION < '3') 38 | gem 'net-smtp' if RUBY_VERSION >= '3.1' 39 | gem 'jbuilder' unless ENV['API'] == '1' 40 | gem 'mutex_m' if RUBY_VERSION >= '3.4' 41 | gem 'base64' if RUBY_VERSION >= '3.4' 42 | gem 'bigdecimal' if RUBY_VERSION >= '3.4' 43 | gem 'logger' if RUBY_VERSION >= '3.5' 44 | gem 'benchmark' if RUBY_VERSION >= '3.5' 45 | -------------------------------------------------------------------------------- /test/features/association_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class AssociationIntegrationTest < ActionDispatch::IntegrationTest 6 | setup do 7 | company = Company.create! name: 'NaCl' 8 | @matz = company.authors.create! name: 'matz' 9 | @matz.books.create!( 10 | title: 'the world of code', 11 | publisher_attributes: { name: 'nikkei linux' } 12 | ) 13 | @matz.books.create!( 14 | title: 'the ruby programming language', 15 | publisher_attributes: { name: "o'reilly" } 16 | ) 17 | @matz.create_profile! address: 'Matsue city, Shimane' 18 | @matz.profile.create_profile_history! updated_on: Date.new(2017, 2, 7) 19 | @matz.magazines.create! title: 'rubima' 20 | end 21 | 22 | test 'decorating associated objects' do 23 | visit "/authors/#{@matz.id}" 24 | assert page.has_content? 'the world of code'.upcase 25 | assert page.has_content? 'the ruby programming language'.upcase 26 | assert page.has_content? 'nikkei linux'.upcase 27 | if Rails.version.to_f >= 5.1 28 | assert page.has_content? 'nikkei linux'.reverse 29 | end 30 | assert page.has_content? 'secret' 31 | assert page.has_content? '2017/02/07' 32 | assert page.has_content? 'rubima'.upcase 33 | assert page.has_content? 'NaCl'.reverse 34 | end 35 | 36 | test "decorating associated objects that owner doesn't have decorator" do 37 | movie = Movie.create! author: @matz 38 | visit "/movies/#{movie.id}" 39 | assert page.has_content? 'matz'.reverse 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/active_decorator/view_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A module that carries the controllers' view_context to decorators. 4 | module ActiveDecorator 5 | # Use Rails' CurrentAttributes if available (Rails 5.2+) 6 | if defined? ActiveSupport::CurrentAttributes 7 | class ViewContext < ActiveSupport::CurrentAttributes 8 | # Rails 7.2+ 9 | if method(:attribute).parameters.include? [:key, :default] 10 | attribute :view_context_stack, default: [] 11 | else 12 | attribute :view_context_stack 13 | 14 | def view_context_stack 15 | attributes[:view_context_stack] ||= [] 16 | end 17 | 18 | resets do 19 | self.view_context_stack = nil 20 | end 21 | end 22 | end 23 | else 24 | # Fallback implementation for Rails < 5.2 25 | class ViewContext 26 | class << self 27 | def view_context_stack 28 | Thread.current[:active_decorator_view_contexts] ||= [] 29 | end 30 | end 31 | end 32 | end 33 | 34 | class ViewContext 35 | class << self 36 | def current 37 | view_context_stack.last 38 | end 39 | 40 | def push(view_context) 41 | view_context_stack.push view_context 42 | end 43 | 44 | def pop 45 | view_context_stack.pop 46 | end 47 | 48 | def run_with(view_context) 49 | push view_context 50 | yield 51 | ensure 52 | pop 53 | end 54 | end 55 | 56 | module Filter 57 | extend ActiveSupport::Concern 58 | 59 | included do 60 | around_action do |controller, blk| 61 | ActiveDecorator::ViewContext.run_with(controller.view_context) do 62 | blk.call 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/models/association_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class AssociationTest < Test::Unit::TestCase 6 | setup do 7 | a = Author.create! name: 'pragdave' 8 | ActiveDecorator::Decorator.instance.decorate a 9 | 10 | @books = a.books 11 | 12 | b = @books.create! title: 'pragprog' 13 | @id = b.id 14 | end 15 | 16 | test 'build' do 17 | b = @books.build title: 'pickaxe' 18 | assert b.is_a? ActiveDecorator::Decorated 19 | end 20 | 21 | test 'create!' do 22 | b = @books.create! title: 'pickaxe' 23 | assert b.is_a? ActiveDecorator::Decorated 24 | end 25 | 26 | test 'each' do 27 | @books.each do |b| 28 | assert b.is_a? ActiveDecorator::Decorated 29 | end 30 | end 31 | 32 | test 'first' do 33 | assert @books.first.is_a? ActiveDecorator::Decorated 34 | end 35 | 36 | test 'last' do 37 | assert @books.last.is_a? ActiveDecorator::Decorated 38 | end 39 | 40 | test 'find' do 41 | assert @books.find(@id).is_a? ActiveDecorator::Decorated 42 | end 43 | 44 | test 'take' do 45 | assert @books.take.is_a? ActiveDecorator::Decorated 46 | end 47 | 48 | sub_test_case 'when method chained' do 49 | setup do 50 | @books = @books.order(:id) 51 | end 52 | 53 | test 'each' do 54 | @books.each do |b| 55 | assert b.is_a? ActiveDecorator::Decorated 56 | end 57 | end 58 | 59 | test 'first' do 60 | assert @books.first.is_a? ActiveDecorator::Decorated 61 | end 62 | 63 | test 'last' do 64 | assert @books.last.is_a? ActiveDecorator::Decorated 65 | end 66 | 67 | test 'find' do 68 | assert @books.find(@id).is_a? ActiveDecorator::Decorated 69 | end 70 | 71 | test 'take' do 72 | assert @books.take.is_a? ActiveDecorator::Decorated 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 4 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 5 | # require logger before rails or Rails 6 fails to boot 6 | require 'logger' 7 | # load Rails first 8 | require 'rails' 9 | 10 | # load the plugin 11 | require 'active_decorator' 12 | 13 | Bundler.require 14 | begin 15 | require 'rackup/handler' 16 | # Work around "uninitialized constant Rack::Handler" on Capybara here: https://github.com/teamcapybara/capybara/blob/0480f90168a40780d1398c75031a255c1819dce8/lib/capybara/registrations/servers.rb#L31 17 | ::Rack::Handler = ::Rackup::Handler unless defined?(::Rack::Handler) 18 | rescue LoadError 19 | require 'rack/handler' 20 | end 21 | require 'capybara' 22 | require 'selenium/webdriver' 23 | require 'byebug' 24 | 25 | # needs to load the app next 26 | require 'rails_app/app' 27 | 28 | require 'test/unit/rails/test_help' 29 | 30 | begin 31 | require 'action_dispatch/system_test_case' 32 | rescue LoadError 33 | Capybara.register_driver :chrome do |app| 34 | options = Selenium::WebDriver::Chrome::Options.new(args: %w[no-sandbox headless disable-gpu]) 35 | Capybara::Selenium::Driver.new(app, browser: :chrome, options: options) 36 | end 37 | Capybara.javascript_driver = :chrome 38 | 39 | class ActionDispatch::IntegrationTest 40 | include Capybara::DSL 41 | end 42 | else 43 | if ActionPack::VERSION::STRING > '5.2' 44 | ActionDispatch::SystemTestCase.driven_by :selenium, using: :headless_chrome 45 | else 46 | ActionDispatch::SystemTestCase.driven_by :selenium_chrome_headless 47 | end 48 | end 49 | 50 | module DatabaseDeleter 51 | def setup 52 | Book.delete_all 53 | Author.delete_all 54 | Movie.delete_all 55 | super 56 | end 57 | end 58 | 59 | Test::Unit::TestCase.send :prepend, DatabaseDeleter 60 | 61 | CreateAllTables.up unless ActiveRecord::Base.connection.table_exists? 'authors' 62 | -------------------------------------------------------------------------------- /lib/active_decorator/monkey/active_record/associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A monkey-patch for Active Record that enables association auto-decoration. 4 | module ActiveDecorator 5 | module Monkey 6 | module ActiveRecord 7 | module Associations 8 | module Association 9 | def target 10 | ActiveDecorator::Decorator.instance.decorate_association(owner, super) 11 | end 12 | end 13 | 14 | if Rails.version.to_f < 5.1 15 | module CollectionAssociation 16 | private 17 | def first_nth_or_last(*) 18 | ActiveDecorator::Decorator.instance.decorate_association(owner, super) 19 | end 20 | end 21 | end 22 | 23 | module CollectionProxy 24 | def take(*) 25 | ActiveDecorator::Decorator.instance.decorate_association(@association.owner, super) 26 | end 27 | 28 | if Rails.version.to_f >= 5.1 29 | def last(*) 30 | ActiveDecorator::Decorator.instance.decorate_association(@association.owner, super) 31 | end 32 | 33 | private 34 | 35 | def find_nth_with_limit(*) 36 | ActiveDecorator::Decorator.instance.decorate_association(@association.owner, super) 37 | end 38 | 39 | def find_nth_from_last(*) 40 | ActiveDecorator::Decorator.instance.decorate_association(@association.owner, super) 41 | end 42 | end 43 | end 44 | 45 | module CollectionAssociation 46 | private 47 | 48 | def build_record(*) 49 | ActiveDecorator::Decorator.instance.decorate_association(@owner, super) 50 | end 51 | end 52 | end 53 | 54 | module AssociationRelation 55 | def spawn(*) 56 | ActiveDecorator::Decorator.instance.decorate_association(@association.owner, super) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.5.1 2 | 3 | * Removed unnecessary `ruby2_keywords` call that emits "warning: Skipping set of ruby2_keywords flag" [@t-kinoshita] 4 | 5 | * Fixed another warning "assigned but unused variable - view_context_stack" 6 | 7 | 8 | ## 1.5.0 9 | 10 | * Don't drop keyword arguments when delegating helper methods via method_missing 11 | 12 | * Use ActiveSupport::CurrentAttributes (if available) for storing ViewContext instead of `Thread.current` for better thread safety 13 | 14 | 15 | ## 1.4.1 16 | 17 | * Support Ruby 3.2 by `File.exists?` => `File.exist?` in generator [@kyoshidajp] 18 | 19 | * A little bit of internal code cleanup 20 | 21 | 22 | ## 1.4.0 23 | 24 | * Decorate non-nil objects where `nil?` returns true, namely, ActionText::RichText body [@jamesbrooks] 25 | 26 | 27 | ## 1.3.4 28 | 29 | * Support Rails 6.1 [@y-yagi] 30 | 31 | 32 | ## 1.3.3 33 | 34 | * Fixed Ruby 2.7 keyword arguments warning [@pocke] 35 | 36 | 37 | ## 1.3.2 38 | 39 | * Fixed NameError on ActionController::API controllers without jbuilder enhancement [@kamillle] 40 | 41 | 42 | ## 1.3.1 43 | 44 | * Switched back from Ruby's `const_get` to Active Support `constantize` for fetching decorator modules, due to inability to properly detect namespaced decorator [@sinsoku] 45 | 46 | 47 | ## 1.3.0 48 | 49 | * Switched from Active Support `constantize` to Ruby's `const_get` when fetching decorator modules 50 | 51 | * Switched `config` from ActiveSupport::Configurable to a simple Struct 52 | 53 | * Association decoration now propagates from AssociationRelation to spawned Relations (e.g. `@post.comments.order(:id).each`) 54 | 55 | * Dropped support for Rails 3.2, 4.0, and 4.1 56 | 57 | 58 | ## 1.2.0 59 | 60 | * Decorate values in Hash recursively [@FumiyaShibusawa] 61 | 62 | 63 | ## 1.1.1 64 | 65 | * Improved ActionController::API support for Rails 5.0.x [@frodsan] 66 | 67 | * Fixed "NameError: undefined local variable or method `view_context'" with ActionController::API or when rendering in controllers 68 | 69 | 70 | ## 1.1.0 71 | 72 | * ActionController::API support [@frodsan] 73 | 74 | * `ActiveDecorator::Decorator.instance.decorate` now returns the decorated object when the receiver was already a decorated object (it used to return `nil`) [@velonica1997] 75 | 76 | * Update decorator_spec.rb syntax to respect RSpec 3 style [@memoht] 77 | 78 | * Fixed namespace for TestUnit generator with some refactorings [@yhirano55] 79 | -------------------------------------------------------------------------------- /test/decorator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class DecoratorTest < Test::Unit::TestCase 6 | test 'it returns the object on decoration' do 7 | book = Book.new title: 'Boek' 8 | assert_equal book, ActiveDecorator::Decorator.instance.decorate(book) 9 | end 10 | 11 | test 'it returns the object when it already is decorated on decorate' do 12 | book = Book.new title: 'Boek' 13 | assert_equal book, ActiveDecorator::Decorator.instance.decorate(ActiveDecorator::Decorator.instance.decorate(book)) 14 | end 15 | 16 | test 'it returns the object of ActiveRecord::Relation on decorate' do 17 | 3.times do |index| 18 | Book.create title: "ve#{index}" 19 | end 20 | 21 | books = Book.all 22 | assert_equal books, ActiveDecorator::Decorator.instance.decorate(books) 23 | end 24 | 25 | test 'it returns the object of ActiveRecord::Relation when it already is decorated on decorate' do 26 | 3.times do |index| 27 | Book.create title: "ve#{index}" 28 | end 29 | 30 | books = Book.all 31 | assert_equal books, ActiveDecorator::Decorator.instance.decorate(ActiveDecorator::Decorator.instance.decorate(books)) 32 | end 33 | 34 | test 'decorating Array decorates its each element' do 35 | array = [Book.new(title: 'Boek')] 36 | assert_equal array, ActiveDecorator::Decorator.instance.decorate(array) 37 | 38 | array.each do |value| 39 | assert value.is_a?(BookDecorator) 40 | end 41 | end 42 | 43 | test 'decorating Hash decorates its each value' do 44 | hash = {some_record: Book.new(title: 'Boek')} 45 | assert_equal hash, ActiveDecorator::Decorator.instance.decorate(hash) 46 | 47 | hash.each_value do |value| 48 | assert value.is_a?(BookDecorator) 49 | end 50 | end 51 | 52 | test "Don't use the wrong decorator for nested classes" do 53 | comic = Foo::Comic.new 54 | 55 | assert_equal comic, ActiveDecorator::Decorator.instance.decorate(comic) 56 | assert !comic.is_a?(ComicDecorator) 57 | end 58 | 59 | test "it returns the object when nil? returns true on decorate" do 60 | record = NilRecord.new 61 | 62 | assert_equal record, ActiveDecorator::Decorator.instance.decorate(record) 63 | assert record.is_a?(NilRecordDecorator) 64 | end 65 | 66 | test 'nil, true, and false are not decorated' do 67 | assert_equal nil, ActiveDecorator::Decorator.instance.decorate(nil) 68 | assert_not_respond_to nil, :do 69 | assert_equal true, ActiveDecorator::Decorator.instance.decorate(true) 70 | assert_not_respond_to true, :do 71 | assert_equal false, ActiveDecorator::Decorator.instance.decorate(false) 72 | assert_not_respond_to false, :do 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/active_decorator/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDecorator 4 | class Railtie < ::Rails::Railtie 5 | initializer 'active_decorator' do 6 | ActiveSupport.on_load :action_view do 7 | require 'active_decorator/monkey/action_view/partial_renderer' 8 | ActionView::PartialRenderer.send :prepend, ActiveDecorator::Monkey::ActionView::PartialRenderer 9 | 10 | if Rails.version.to_f >= 6.1 11 | require 'active_decorator/monkey/action_view/collection_renderer' 12 | ActionView::CollectionRenderer.send :prepend, ActiveDecorator::Monkey::ActionView::CollectionRenderer 13 | 14 | require 'active_decorator/monkey/action_view/object_renderer' 15 | ActionView::ObjectRenderer.send :prepend, ActiveDecorator::Monkey::ActionView::ObjectRenderer 16 | end 17 | end 18 | 19 | ActiveSupport.on_load :action_controller do 20 | require 'active_decorator/monkey/abstract_controller/rendering' 21 | ::ActionController::Base.send :prepend, ActiveDecorator::Monkey::AbstractController::Rendering 22 | 23 | require 'active_decorator/monkey/action_controller/base/rescue_from' 24 | ActionController::Base.send :prepend, ActiveDecorator::Monkey::ActionController::Base 25 | 26 | require 'active_decorator/view_context' 27 | ActionController::Base.send :include, ActiveDecorator::ViewContext::Filter 28 | end 29 | 30 | if Rails::VERSION::MAJOR >= 5 31 | ActiveSupport.on_load :action_controller do 32 | if self == ActionController::API 33 | require 'active_decorator/monkey/abstract_controller/rendering' 34 | ::ActionController::API.send :prepend, ActiveDecorator::Monkey::AbstractController::Rendering 35 | 36 | require 'active_decorator/monkey/action_controller/base/rescue_from' 37 | ::ActionController::API.send :prepend, ActiveDecorator::Monkey::ActionController::Base 38 | end 39 | end 40 | end 41 | 42 | ActiveSupport.on_load :action_mailer do 43 | require 'active_decorator/monkey/abstract_controller/rendering' 44 | ActionMailer::Base.send :prepend, ActiveDecorator::Monkey::AbstractController::Rendering 45 | 46 | if ActionMailer::Base.respond_to? :before_action 47 | require 'active_decorator/view_context' 48 | ActionMailer::Base.send :include, ActiveDecorator::ViewContext::Filter 49 | end 50 | end 51 | 52 | ActiveSupport.on_load :active_record do 53 | require 'active_decorator/monkey/active_record/associations' 54 | ActiveRecord::Associations::Association.send :prepend, ActiveDecorator::Monkey::ActiveRecord::Associations::Association 55 | 56 | if Rails.version.to_f < 5.1 57 | ActiveRecord::Associations::CollectionAssociation.send :prepend, ActiveDecorator::Monkey::ActiveRecord::Associations::CollectionAssociation 58 | end 59 | 60 | ActiveRecord::AssociationRelation.send :prepend, ActiveDecorator::Monkey::ActiveRecord::AssociationRelation 61 | 62 | ActiveRecord::Associations::CollectionProxy.send :prepend, ActiveDecorator::Monkey::ActiveRecord::Associations::CollectionProxy 63 | ActiveRecord::Associations::CollectionAssociation.send :prepend, ActiveDecorator::Monkey::ActiveRecord::Associations::CollectionAssociation 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/active_decorator/decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'singleton' 4 | require 'active_decorator/helpers' 5 | require 'active_decorator/decorated' 6 | 7 | module ActiveDecorator 8 | class Decorator 9 | include Singleton 10 | 11 | def initialize 12 | @decorators = {} 13 | end 14 | 15 | # Decorates the given object. 16 | # Plus, performs special decoration for the classes below: 17 | # Array: decorates its each element 18 | # Hash: decorates its each value 19 | # AR::Relation: decorates its each record lazily 20 | # AR model: decorates its associations on the fly 21 | # 22 | # Always returns the object, regardless of whether decorated or not decorated. 23 | # 24 | # This method can be publicly called from anywhere by `ActiveDecorator::Decorator.instance.decorate(obj)`. 25 | def decorate(obj) 26 | return obj if defined?(Jbuilder) && (Jbuilder === obj) 27 | 28 | case obj 29 | when Array 30 | obj.each {|e| decorate e } 31 | when Hash 32 | obj.each_value {|v| decorate v } 33 | when nil, true, false 34 | # Do nothing 35 | else 36 | if defined? ActiveRecord 37 | if obj.is_a? ActiveRecord::Relation 38 | return decorate_relation obj 39 | elsif ActiveRecord::Base === obj 40 | obj.extend ActiveDecorator::Decorated unless ActiveDecorator::Decorated === obj 41 | end 42 | end 43 | 44 | d = decorator_for obj.class 45 | obj.extend d if d && !(d === obj) 46 | end 47 | 48 | obj 49 | end 50 | 51 | # Decorates AR model object's association only when the object was decorated. 52 | # Returns the association instance. 53 | def decorate_association(owner, target) 54 | (ActiveDecorator::Decorated === owner) ? decorate(target) : target 55 | end 56 | 57 | private 58 | # Returns a decorator module for the given class. 59 | # Returns `nil` if no decorator module was found. 60 | def decorator_for(model_class) 61 | return @decorators[model_class] if @decorators.key? model_class 62 | 63 | decorator_name = "#{model_class.name}#{ActiveDecorator.config.decorator_suffix}" 64 | d = decorator_name.constantize 65 | unless Class === d 66 | d.send :include, ActiveDecorator::Helpers 67 | @decorators[model_class] = d 68 | else 69 | # Cache nil results 70 | @decorators[model_class] = nil 71 | end 72 | rescue NameError 73 | if model_class.respond_to?(:base_class) && (model_class.base_class != model_class) 74 | @decorators[model_class] = decorator_for model_class.base_class 75 | else 76 | # Cache nil results 77 | @decorators[model_class] = nil 78 | end 79 | end 80 | 81 | # Decorate with proper monkey patch based on AR version 82 | def decorate_relation(obj) 83 | if obj.respond_to?(:records) 84 | # Rails 5.0 85 | obj.extend ActiveDecorator::RelationDecorator unless ActiveDecorator::RelationDecorator === obj 86 | else 87 | # Rails 3.x and 4.x 88 | obj.extend ActiveDecorator::RelationDecoratorLegacy unless ActiveDecorator::RelationDecoratorLegacy === obj 89 | end 90 | obj 91 | end 92 | end 93 | 94 | # Override AR::Relation#records to decorate each element after being loaded (for AR 5+) 95 | module RelationDecorator 96 | def records 97 | ActiveDecorator::Decorator.instance.decorate super 98 | end 99 | end 100 | 101 | # Override AR::Relation#to_a to decorate each element after being loaded (for AR 3 and 4) 102 | module RelationDecoratorLegacy 103 | def to_a 104 | ActiveDecorator::Decorator.instance.decorate super 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '45 23 * * *' 8 | 9 | jobs: 10 | build: 11 | name: Ruby ${{ matrix.ruby_version }} / Rails ${{ matrix.rails_version }}${{ matrix.api == '1' && ' / API' || '' }} 12 | strategy: 13 | matrix: 14 | ruby_version: [ruby-head, '3.4', '3.3', '3.2', '3.1'] 15 | rails_version: [edge, '8.0', '7.2', '7.1', '7.0', '6.1'] 16 | api: ['0', '1'] 17 | 18 | include: 19 | - ruby_version: '3.0' 20 | rails_version: '7.1' 21 | - ruby_version: '3.0' 22 | rails_version: '7.1' 23 | api: '1' 24 | - ruby_version: '3.0' 25 | rails_version: '7.0' 26 | - ruby_version: '3.0' 27 | rails_version: '7.0' 28 | api: '1' 29 | - ruby_version: '3.0' 30 | rails_version: '6.1' 31 | - ruby_version: '3.0' 32 | rails_version: '6.1' 33 | api: '1' 34 | 35 | - ruby_version: '2.7' 36 | rails_version: '7.1' 37 | - ruby_version: '2.7' 38 | rails_version: '7.1' 39 | api: '1' 40 | - ruby_version: '2.7' 41 | rails_version: '7.0' 42 | - ruby_version: '2.7' 43 | rails_version: '7.0' 44 | api: '1' 45 | - ruby_version: '2.7' 46 | rails_version: '6.1' 47 | - ruby_version: '2.7' 48 | rails_version: '6.1' 49 | api: '1' 50 | - ruby_version: '2.7' 51 | rails_version: '6.0' 52 | - ruby_version: '2.7' 53 | rails_version: '6.0' 54 | api: '1' 55 | 56 | - ruby_version: '2.6' 57 | rails_version: '6.1' 58 | - ruby_version: '2.6' 59 | rails_version: '6.1' 60 | api: '1' 61 | - ruby_version: '2.6' 62 | rails_version: '6.0' 63 | - ruby_version: '2.6' 64 | rails_version: '6.0' 65 | api: '1' 66 | - ruby_version: '2.6' 67 | rails_version: '5.2' 68 | - ruby_version: '2.6' 69 | rails_version: '5.2' 70 | api: '1' 71 | - ruby_version: '2.6' 72 | rails_version: '5.1' 73 | - ruby_version: '2.6' 74 | rails_version: '5.1' 75 | api: '1' 76 | - ruby_version: '2.6' 77 | rails_version: '5.0' 78 | - ruby_version: '2.6' 79 | rails_version: '5.0' 80 | api: '1' 81 | 82 | - ruby_version: '2.5' 83 | rails_version: '5.2' 84 | 85 | - ruby_version: '2.4' 86 | rails_version: '5.2' 87 | 88 | - ruby_version: '2.3' 89 | rails_version: '5.2' 90 | - ruby_version: '2.3' 91 | rails_version: '4.2' 92 | bundler_version: '1' 93 | 94 | - ruby_version: '2.2' 95 | rails_version: '5.2' 96 | 97 | - ruby_version: '2.1' 98 | rails_version: '4.2' 99 | bundler_version: '1' 100 | 101 | exclude: 102 | - ruby_version: '3.1' 103 | rails_version: edge 104 | - ruby_version: '3.1' 105 | rails_version: edge 106 | api: '1' 107 | - ruby_version: '3.1' 108 | rails_version: '8.0' 109 | - ruby_version: '3.1' 110 | rails_version: '8.0' 111 | api: '1' 112 | 113 | env: 114 | RAILS_VERSION: ${{ matrix.rails_version }} 115 | API: ${{ matrix.api }} 116 | 117 | runs-on: ubuntu-24.04 118 | 119 | steps: 120 | - uses: actions/checkout@v6 121 | 122 | - uses: ruby/setup-ruby@v1 123 | with: 124 | ruby-version: ${{ matrix.ruby_version }} 125 | rubygems: ${{ (matrix.ruby_version < '2.7' && 'default') || (matrix.ruby_version < '3' && '3.4.22') || 'latest' }} 126 | bundler: ${{ matrix.bundler_version }} 127 | bundler-cache: true 128 | continue-on-error: ${{ matrix.allow_failures == 'true' }} 129 | 130 | - run: bundle exec rake 131 | continue-on-error: ${{ matrix.allow_failures == 'true' }} 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveDecorator [](https://github.com/amatsuda/active_decorator/actions) [](https://codeclimate.com/github/amatsuda/active_decorator) 2 | 3 | A simple and Rubyish view helper for Rails 4, Rails 5, Rails 6, Rails 7, and Rails 8. Keep your helpers and views Object-Oriented! 4 | 5 | 6 | ## Features ## 7 | 8 | 1. automatically mixes decorator module into corresponding model only when: 9 | 1. passing a model or collection of models or an instance of ActiveRecord::Relation from controllers to views 10 | 2. rendering partials with models (using `:collection` or `:object` or `:locals` explicitly or implicitly) 11 | 3. fetching already decorated Active Record model object's association 12 | 2. the decorator module runs in the model's context. So, you can directly call any attributes or methods in the decorator module 13 | 3. since decorators are considered as sort of helpers, you can also call any ActionView's helper methods such as `content_tag` or `link_to` 14 | 15 | 16 | ## Supported versions ## 17 | 18 | * Ruby 2.1.x, 2.2.x, 2.3.x, 2.4.x, 2.5.x, 2.6.x, 2.7.x, 3.0.x, 3.1.x, 3.2.x, 3.3.x, 3.4.x, and 3.5 (trunk) 19 | 20 | * Rails 4.2.x, 5.0, 5.1, 5.2, 6.0, 6.1, 7.0, 7.1, 7.2, 8.0, and 8.1 (edge) 21 | 22 | 23 | ## Supported ORMs ## 24 | 25 | ActiveRecord, ActiveResource, and any kind of ORMs that uses Ruby Objects as model objects 26 | 27 | 28 | ## Usage ## 29 | 30 | 1. bundle 'active_decorator' gem 31 | 2. create a decorator module for each AR model. For example, a decorator for a model `User` should be named `UserDecorator`. 32 | You can use the generator for doing this ( `% rails g decorator user` ) 33 | 3. Then it's all done. Without altering any single line of the existing code, the decorator modules will be automatically mixed into your models only in the view context. 34 | 35 | 36 | ## Examples ## 37 | 38 | ### Auto-decorating via `render` ### 39 | 40 | * Model 41 | ```ruby 42 | class Author < ActiveRecord::Base 43 | # first_name:string last_name:string 44 | end 45 | ``` 46 | 47 | * Controller 48 | ```ruby 49 | class AuthorsController < ApplicationController 50 | def show(id) # powered by action_args 51 | @author = Author.find id 52 | end 53 | end 54 | ``` 55 | 56 | * Decorator 57 | ```ruby 58 | module AuthorDecorator 59 | def full_name 60 | "#{first_name} #{last_name}" 61 | end 62 | end 63 | ``` 64 | 65 | * View 66 | ```erb 67 | <%# @author here is auto-decorated in between the controller and the view %> 68 |
<%= @author.full_name %>
69 | ``` 70 | 71 | ### Auto-decorating via AR model's associated objects ### 72 | 73 | * Models 74 | ```ruby 75 | class Author < ActiveRecord::Base 76 | # name:string 77 | has_many :books 78 | end 79 | 80 | class Book < ActiveRecord::Base 81 | # title:string url:string 82 | belongs_to :author 83 | end 84 | ``` 85 | 86 | * Controller 87 | ```ruby 88 | class AuthorsController < ApplicationController 89 | def show(id) 90 | @author = Author.find id 91 | end 92 | end 93 | ``` 94 | 95 | * Decorator 96 | ```ruby 97 | module BookDecorator 98 | def link 99 | link_to title, url 100 | end 101 | end 102 | ``` 103 | 104 | * View 105 | ```erb 106 |<%= @author.name %>
107 |