├── Gemfile ├── lib ├── oprah │ ├── version.rb │ ├── test_helpers.rb │ ├── railtie.rb │ ├── cache.rb │ ├── controller_helpers.rb │ └── presenter.rb └── oprah.rb ├── tasks ├── bundler.rb ├── minitest.rb ├── yard.rb └── console.rb ├── Rakefile ├── .gitignore ├── CHANGELOG.md ├── test ├── oprah │ ├── oprah_test.rb │ ├── controller_helpers_test.rb │ └── presenter_test.rb └── helper.rb ├── .travis.yml ├── LICENSE ├── oprah.gemspec ├── CONTRIBUTING.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in sheaf.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/oprah/version.rb: -------------------------------------------------------------------------------- 1 | module Oprah 2 | # @return [String] The Oprah library version. 3 | VERSION = "0.1.2" 4 | end 5 | -------------------------------------------------------------------------------- /tasks/bundler.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_helper' 2 | 3 | namespace :gem do 4 | Bundler::GemHelper.install_tasks name: 'oprah' 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require File.join(Dir.pwd, 'lib', 'oprah', 'version') 2 | 3 | Dir["tasks/**/*.rb"].each { |task| load task } 4 | 5 | task default: :test 6 | -------------------------------------------------------------------------------- /tasks/minitest.rb: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.libs << "test" 5 | t.test_files = FileList['test/**/*_test.rb'] 6 | t.verbose = false 7 | end 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.1.2 2 | ----- 3 | 4 | - Explicitly rescue NameError in Cache#presenter_classes_for 5 | - Add Oprah::TestHelpers 6 | - Delegate #to_s and #inspect to the presented object 7 | 8 | 0.1.1 9 | ----- 10 | 11 | - Initial public release 12 | -------------------------------------------------------------------------------- /lib/oprah/test_helpers.rb: -------------------------------------------------------------------------------- 1 | module Oprah 2 | module TestHelpers 3 | def present_many(*args, &block) 4 | Oprah.present_many(*args, &block) 5 | end 6 | 7 | def present(*args, &block) 8 | Oprah.present(*args, &block) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /tasks/yard.rb: -------------------------------------------------------------------------------- 1 | require 'yard' 2 | require 'yard/rake/yardoc_task' 3 | 4 | YARD::Rake::YardocTask.new(:doc) do |t| 5 | t.files = ['lib/**/*.rb'] 6 | t.options = %w{ 7 | --verbose 8 | --markup markdown 9 | --readme README.md 10 | --tag comment 11 | --hide-tag comment 12 | --hide-void-return 13 | -M 14 | redcarpet 15 | } 16 | end 17 | -------------------------------------------------------------------------------- /test/oprah/oprah_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Oprah 4 | class Test < Minitest::Test 5 | def test_debug 6 | old = ENV["OPRAH_DEBUG"] 7 | 8 | ENV["OPRAH_DEBUG"] = nil 9 | refute Oprah.debug? 10 | 11 | ENV["OPRAH_DEBUG"] = "1" 12 | assert Oprah.debug? 13 | 14 | ENV["OPRAH_DEBUG"] = old 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | cache: bundler 4 | script: rake test 5 | rvm: 6 | - 2.3.1 7 | matrix: 8 | global: 9 | - BUNDLE_JOBS=4 10 | before_install: 11 | - bundle install --retry=3 12 | before_update: 13 | - bundle update 14 | notifications: 15 | email: false 16 | addons: 17 | code_climate: 18 | repo_token: 3d9c9c681c7be79695156ecbf9978ada542e60b726a7ba728866dee6f95f3ccd 19 | -------------------------------------------------------------------------------- /lib/oprah/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | 3 | module Oprah 4 | class Railtie < Rails::Railtie 5 | initializer "oprah.configure_cache_clear_on_code_reload" do 6 | ActiveSupport::Reloader.to_run do 7 | Oprah::Presenter.cache.clear! 8 | end 9 | end 10 | 11 | initializer "oprah.configure_action_controller_helpers" do 12 | ActiveSupport.on_load :action_controller do 13 | ActionController::Base.include(Oprah::ControllerHelpers) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /tasks/console.rb: -------------------------------------------------------------------------------- 1 | class RakeConsole 2 | GEM = Dir["*.gemspec"].first.sub('.gemspec', '') 3 | REQUIRE_PATH = File.join(Dir.pwd, 'lib', GEM) 4 | 5 | module Helpers 6 | def reload! 7 | puts "Reloading..." 8 | $LOADED_FEATURES.select do |feat| 9 | feat =~ /\/#{GEM}\// 10 | end.each { |file| load file } 11 | true 12 | end 13 | end 14 | 15 | def start 16 | require REQUIRE_PATH 17 | ARGV.clear 18 | Object.include(Helpers) 19 | 20 | begin 21 | require 'pry' 22 | TOPLEVEL_BINDING.pry 23 | rescue LoadError 24 | require 'irb' 25 | require 'irb/completion' 26 | IRB.start 27 | end 28 | end 29 | end 30 | 31 | desc "Start development console" 32 | task :console do 33 | RakeConsole.new.start 34 | end 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2016 Tobias Svensson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'minitest/pride' 3 | require 'oprah' 4 | require 'oprah/test_helpers' 5 | 6 | class Minitest::Test 7 | include Oprah::TestHelpers 8 | end 9 | 10 | module Fixtures 11 | module Entity 12 | end 13 | 14 | class EntityPresenter < Oprah::Presenter 15 | def foo 16 | "foo" 17 | end 18 | end 19 | 20 | class User 21 | include Entity 22 | 23 | def first_name 24 | "Foo" 25 | end 26 | 27 | def last_name 28 | "Bar" 29 | end 30 | 31 | private 32 | 33 | def password 34 | "baz" 35 | end 36 | end 37 | 38 | class UserPresenter < Oprah::Presenter 39 | def name 40 | [first_name, last_name].join(' ') 41 | end 42 | 43 | def foo 44 | super + "bar" 45 | end 46 | end 47 | 48 | class Comment 49 | end 50 | 51 | class CommentPresenter < Oprah::Presenter 52 | end 53 | 54 | class Project 55 | def comments 56 | Array.new(3) { Comment.new } 57 | end 58 | 59 | def owner 60 | User.new 61 | end 62 | end 63 | 64 | class ProjectPresenter < Oprah::Presenter 65 | presents_many :comments 66 | presents_one :owner 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /oprah.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'oprah/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "oprah" 8 | gem.version = Oprah::VERSION 9 | gem.authors = ["Tobias Svensson"] 10 | gem.email = ["tob@tobiassvensson.co.uk"] 11 | gem.summary = "Opinionated presenters for Rails 5 - without the cruft" 12 | gem.description = gem.summary 13 | gem.homepage = "https://github.com/endofunky/oprah" 14 | gem.license = "Apache License, Version 2.0" 15 | 16 | gem.files = `git ls-files -z`.split("\x0") 17 | gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 19 | gem.require_paths = ["lib"] 20 | 21 | gem.has_rdoc = 'yard' 22 | 23 | gem.add_dependency "activesupport", ">= 5.0.0" 24 | gem.add_dependency "actionpack", ">= 5.0.0" 25 | 26 | gem.add_development_dependency "yard", "~> 0.9.5" 27 | gem.add_development_dependency "redcarpet", "~> 3.3.4" 28 | gem.add_development_dependency "bundler", "~> 1.7" 29 | gem.add_development_dependency "rake", "~> 10.0" 30 | gem.add_development_dependency "minitest", "~> 5.5.1" 31 | end 32 | -------------------------------------------------------------------------------- /lib/oprah.rb: -------------------------------------------------------------------------------- 1 | # stdlib 2 | require 'forwardable' 3 | require 'singleton' 4 | 5 | # gems 6 | require 'active_support/concern' 7 | require 'active_support/inflector' 8 | require 'action_controller' 9 | 10 | # internal 11 | require 'oprah/cache' 12 | require 'oprah/controller_helpers' 13 | require 'oprah/presenter' 14 | require 'oprah/version' 15 | 16 | require 'oprah/railtie' if defined?(Rails) 17 | 18 | # The Oprah namespace. 19 | module Oprah 20 | # @!visibility private 21 | def debug? 22 | !!ENV["OPRAH_DEBUG"] 23 | end 24 | 25 | # Shortcut to {Oprah::Presenter#present}. 26 | # 27 | # @param object [Object] The object to present 28 | # @param view_context [ActionView::Context] View context to assign 29 | # @return [Presenter] Presented object 30 | def present(object, view_context: Presenter.default_view_context) 31 | Presenter.present(object, view_context: view_context) 32 | end 33 | 34 | # Shortcut to {Presenter#present_many}. 35 | # 36 | # @param objects [Enumerable] The objects to present 37 | # @param view_context [ActionView::Context] View context to assign 38 | # @return [Enumerable] Presented collection 39 | def present_many(objects, view_context: Presenter.default_view_context) 40 | Presenter.present_many(objects, view_context: view_context) 41 | end 42 | 43 | extend self 44 | end 45 | -------------------------------------------------------------------------------- /lib/oprah/cache.rb: -------------------------------------------------------------------------------- 1 | module Oprah 2 | # A cache store to keep Object-to-Presenter mappings. This class is 3 | # thread-safe. 4 | class Cache 5 | def initialize 6 | @mutex = Mutex.new 7 | @mapping = {} 8 | end 9 | 10 | # Looks up presenters matching to `object` and stores them in the cache. 11 | # 12 | # @param object [Object] The presentable object 13 | # @return [Array] An array of Presenter classes 14 | def lookup(object) 15 | @mutex.synchronize do 16 | key = class_name_for(object) 17 | 18 | cached = @mapping[key] 19 | return cached if cached 20 | 21 | @mapping[key] = presenter_classes_for(object) 22 | end 23 | end 24 | 25 | # Clears the presenter cache. 26 | # 27 | # @return [Boolean] 28 | def clear! 29 | @mutex.synchronize do 30 | @mapping = {} 31 | end 32 | 33 | Rails.logger.debug "Oprah cache cleared." if Oprah.debug? 34 | 35 | true 36 | end 37 | 38 | private 39 | 40 | def presenter_classes_for(object) 41 | class_for(object).ancestors.map do |klass| 42 | begin 43 | (klass.name + "Presenter").constantize 44 | rescue NameError 45 | end 46 | end.compact.reverse 47 | end 48 | 49 | def class_name_for(object) 50 | class_for(object).name 51 | end 52 | 53 | def class_for(object) 54 | object.kind_of?(Class) ? object : object.class 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/oprah/controller_helpers.rb: -------------------------------------------------------------------------------- 1 | module Oprah 2 | # Helpers that will be mixed into `ActionController::Base` by 3 | # the {Oprah::Railtie}. 4 | module ControllerHelpers 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | helper_method :present 9 | helper_method :present_many 10 | end 11 | 12 | # Presents the given `object` using {Presenter.present}. 13 | # 14 | # Will pass the view context returned from {#oprah_view_context} to the 15 | # presenter. 16 | # 17 | # @param object [Object] The object to present 18 | # @param view_context [ActionView::Context] View context to assign 19 | # @return [Presenter] Presented object 20 | def present(object, view_context: oprah_view_context) 21 | Oprah.present(object, view_context: view_context) 22 | end 23 | 24 | # Presents the given `objects` using {Presenter.present}. 25 | # 26 | # Will pass the view context returned from {#oprah_view_context} to the 27 | # presenter. 28 | # 29 | # @param objects [Enumerable] The objects to present 30 | # @param view_context [ActionView::Context] View context to assign 31 | # @return [Presenter] Presented object 32 | def present_many(objects, view_context: oprah_view_context) 33 | Oprah.present_many(objects, view_context: view_context) 34 | end 35 | 36 | # The view context automatically passed to presented objects. 37 | # 38 | # You can override this method pass a custom view context to all 39 | # presented objects from the controller scope. 40 | # 41 | # @return [ActionView::Context] 42 | def oprah_view_context 43 | view_context 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/oprah/controller_helpers_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Oprah 4 | class ControllerHelpersTest < Minitest::Test 5 | class Controller 6 | @@helper_methods = [] 7 | 8 | class << self 9 | def helper_methods 10 | @@helper_methods 11 | end 12 | 13 | def helper_method(method) 14 | @@helper_methods << method 15 | end 16 | end 17 | 18 | def view_context 19 | :ok 20 | end 21 | 22 | include Oprah::ControllerHelpers 23 | end 24 | 25 | include Fixtures 26 | 27 | def setup 28 | super 29 | @controller = Controller.new 30 | end 31 | 32 | def test_present 33 | presenter = @controller.present(User.new) 34 | 35 | assert_kind_of UserPresenter, presenter 36 | assert_kind_of EntityPresenter, presenter 37 | 38 | assert_equal :ok, presenter.view_context 39 | end 40 | 41 | def test_present 42 | presenters = @controller.present_many([User.new, User.new]) 43 | 44 | assert_equal 2, presenters.length 45 | 46 | presenters.each do |presenter| 47 | assert_equal "Foo Bar", presenter.name 48 | assert_equal :ok, presenter.view_context 49 | end 50 | end 51 | 52 | def test_present_custom_view_context 53 | presenter = @controller.present(User.new, view_context: :foobar) 54 | assert_equal :foobar, presenter.view_context 55 | end 56 | 57 | def test_helper_method 58 | assert_equal [:present, :present_many], Controller.helper_methods 59 | end 60 | 61 | def test_oprah_view_context 62 | assert_equal @controller.oprah_view_context, @controller.view_context 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish 4 | to make via issue, email, or any other method with the owners of this repository 5 | before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your interactions 8 | with the project. 9 | 10 | ## Pull Request Process 11 | 12 | If you'd like to contribute to Oprah, start by forking the repository on GitHub: 13 | 14 | http://github.com/endofunky/oprah 15 | 16 | To get all of the dependencies, install the gem first. The best way to get your 17 | changes merged back into core is as follows: 18 | 19 | - If you are about to add a larger feature, open an issue on the GitHub issue 20 | tracker to discuss your ideas first. If whatever you're about to implement is 21 | already in the issue tracker, please let us know that you picked it up. 22 | 23 | - Clone down your fork. 24 | 25 | - Create a thoughtfully named topic branch to contain your change. 26 | 27 | - Write some code. 28 | 29 | - Add tests and make sure everything still passes by running bundle exec rake. 30 | This is mandatory for pull requests to be accepted. 31 | 32 | - If you are adding new functionality, document it! 33 | 34 | - Do not change the version number. 35 | 36 | - If necessary, rebase your commits into logical chunks, without errors. 37 | 38 | - Push the branch up to GitHub. 39 | 40 | - Send a pull request to the endofunky/oprah project. 41 | 42 | ## Contributor Code of Conduct 43 | 44 | As contributors and maintainers of this project, and in the interest of 45 | fostering an open and welcoming community, we pledge to respect all people who 46 | contribute through reporting issues, posting feature requests, updating 47 | documentation, submitting pull requests or patches, and other activities. 48 | 49 | We are committed to making participation in this project a harassment-free 50 | experience for everyone, regardless of level of experience, gender, gender 51 | identity and expression, sexual orientation, disability, personal appearance, 52 | body size, race, ethnicity, age, religion, or nationality. 53 | 54 | Examples of unacceptable behavior by participants include: 55 | 56 | - The use of sexualized language or imagery 57 | 58 | - Personal attacks 59 | 60 | - Trolling or insulting/derogatory comments 61 | 62 | - Public or private harassment 63 | 64 | - Publishing other's private information, such as physical or electronic 65 | addresses, without explicit permission 66 | 67 | - Other unethical or unprofessional conduct. 68 | 69 | Project maintainers have the right and responsibility to remove, edit, or 70 | reject comments, commits, code, wiki edits, issues, and other contributions that 71 | are not aligned to this Code of Conduct. By adopting this Code of Conduct, 72 | project maintainers commit themselves to fairly and consistently applying these 73 | principles to every aspect of managing this project. Project maintainers who do 74 | not follow or enforce the Code of Conduct may be permanently removed from the 75 | project team. 76 | 77 | This code of conduct applies both within project spaces and in public spaces 78 | when an individual is representing the project or its community. 79 | 80 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 81 | reported by opening an issue or contacting one or more of the project 82 | maintainers. 83 | 84 | This Code of Conduct is adapted from the 85 | [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, 86 | available at http://contributor-covenant.org/version/1/2/0/ 87 | -------------------------------------------------------------------------------- /test/oprah/presenter_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | module Oprah 4 | class PresenterTest < Minitest::Test 5 | include Fixtures 6 | 7 | def test_cache 8 | assert_kind_of Oprah::Cache, Presenter.cache 9 | assert_equal Presenter.cache, Presenter.cache 10 | assert_equal UserPresenter.cache, Presenter.cache 11 | end 12 | 13 | def test_present_many 14 | present_many([User.new, User.new]).each do |presenter| 15 | assert_equal "Foo Bar", presenter.name 16 | end 17 | end 18 | 19 | def test_presents_many 20 | project = present(Project.new) 21 | 22 | assert_equal 3, project.comments.length 23 | 24 | project.comments.each do |comment| 25 | assert_kind_of Comment, comment 26 | assert_kind_of CommentPresenter, comment 27 | end 28 | end 29 | 30 | def test_presents_one 31 | project = present(Project.new) 32 | owner = project.owner 33 | 34 | assert_kind_of UserPresenter, owner 35 | assert_kind_of User, owner 36 | end 37 | 38 | def test_presenter_stack_ordering 39 | assert_equal "foobar", present(User.new).foo 40 | end 41 | 42 | def test_default_view_context_using_present 43 | presenter = Presenter.present(User.new) 44 | assert_kind_of ActionView::Context, presenter.view_context 45 | end 46 | 47 | def test_default_view_context_using_initialize 48 | presenter = UserPresenter.new(User.new) 49 | assert_kind_of ActionView::Context, presenter.view_context 50 | end 51 | 52 | def test_default_view_context_unique_per_presenter 53 | refute_equal present(User.new).view_context, 54 | present(User.new).view_context 55 | end 56 | 57 | def test_method_missing_delegation 58 | assert_equal "Foo Bar", present(User.new).name 59 | assert_equal "Foo", present(User.new).first_name 60 | assert_equal "Bar", present(User.new).last_name 61 | end 62 | 63 | def test_method_missing_wont_delegate_to_private_methods 64 | assert_raises NoMethodError do 65 | present(User.new).password 66 | end 67 | end 68 | 69 | def test_kind_of? 70 | presenter = present(User.new) 71 | 72 | assert_kind_of User, presenter 73 | assert_kind_of UserPresenter, presenter 74 | assert_kind_of Entity, presenter 75 | assert_kind_of EntityPresenter, presenter 76 | refute_kind_of self.class, presenter 77 | end 78 | 79 | def test_instance_of? 80 | presenter = present(User.new) 81 | 82 | assert_instance_of User, presenter 83 | assert_instance_of UserPresenter, presenter 84 | refute_instance_of Entity, presenter 85 | assert_instance_of EntityPresenter, presenter 86 | refute_instance_of self.class, presenter 87 | end 88 | 89 | def test_equality 90 | project = Project.new 91 | project_presenter = Project.new 92 | 93 | user = User.new 94 | user_presenter = present(user) 95 | 96 | assert_equal user_presenter, user 97 | assert_equal user_presenter, user_presenter 98 | refute_equal user_presenter, project 99 | refute_equal user_presenter, project_presenter 100 | end 101 | 102 | def test_inspect 103 | user = User.new 104 | presenter = present(user) 105 | 106 | assert_equal user.inspect, presenter.inspect 107 | end 108 | 109 | def test_to_s 110 | user = User.new 111 | presenter = present(user) 112 | 113 | assert_equal user.to_s, presenter.to_s 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/oprah/presenter.rb: -------------------------------------------------------------------------------- 1 | module Oprah 2 | class Presenter 3 | extend Forwardable 4 | 5 | # @return [Object] The presented object 6 | attr_reader :object 7 | 8 | # @return [ActionView::Base] The view context 9 | attr_reader :view_context 10 | 11 | alias :h :view_context 12 | 13 | # @!visibility private 14 | @@cache = Oprah::Cache.new 15 | 16 | # @!method inspect 17 | # @see Object#inspect 18 | # @return [String] 19 | # @!method to_s 20 | # @see Object#to_s 21 | # @return [String] 22 | def_delegators :@object, :inspect, :to_s 23 | 24 | class << self 25 | # Returns the shared presenter cache object. 26 | # 27 | # @return [Cache] 28 | def cache 29 | @@cache 30 | end 31 | 32 | # Presents the given `object` with all it's matching presenters, 33 | # following it's ancestors in reverse. 34 | # 35 | # @param object [Object] The object to present 36 | # @param view_context [ActionView::Context] View context to assign 37 | # @return [Presenter] Presented object 38 | def present(object, view_context: default_view_context) 39 | @@cache.lookup(object).inject(object) do |memo, presenter| 40 | presenter.new(memo, view_context: view_context) 41 | end 42 | end 43 | 44 | # Presents the given `objects` with all their matching presenters. 45 | # The individual behavior is the same as `.present`'s. 46 | # 47 | # @param objects [Enumerable] The objects to present 48 | # @param view_context [ActionView::Context] View context to assign 49 | # @return [Enumerable] Presented collection 50 | def present_many(objects, view_context: default_view_context) 51 | objects.map do |object| 52 | present(object, view_context: view_context) 53 | end 54 | end 55 | 56 | # Automatically wrap the objects returned by the given one-to-one 57 | # `association` method in presenters. 58 | # 59 | # @param association [Symbol] Name of the association 60 | # @return [Boolean] 61 | def presents_one(association) 62 | define_method association do 63 | self.class.present( 64 | object.__send__(association), view_context: view_context) 65 | end 66 | end 67 | 68 | # Automatically wrap the objects returned by the given one-to-many 69 | # or many-to-many `association` method in presenters. 70 | # 71 | # @param association [Symbol] Name of the association 72 | # @return [Boolean] 73 | def presents_many(association) 74 | define_method association do 75 | self.class.present_many( 76 | object.__send__(association), view_context: view_context) 77 | end 78 | 79 | true 80 | end 81 | 82 | # Returns the default view context to use if no view context is explicitly 83 | # passed to the presenter. 84 | # 85 | # @return [ActionView::Context] 86 | def default_view_context 87 | ActionController::Base.new.view_context 88 | end 89 | end 90 | 91 | # Initializes a new Presenter. 92 | # 93 | # @param object [Object] The object to present 94 | # @param view_context [ActionView::Context] View context to assign 95 | def initialize(object, view_context: self.class.default_view_context) 96 | @object = object 97 | @view_context = view_context 98 | end 99 | 100 | # Delegates all method calls not handled by the presenter to `object`. 101 | def method_missing(meth, *args, &block) 102 | if respond_to?(meth) 103 | object.__send__(meth, *args, &block) 104 | else 105 | super 106 | end 107 | end 108 | 109 | # Returns true if either `object` or `self` responds to the given method 110 | # name. 111 | # 112 | # @param method [Symbol] Name of the method 113 | # @param include_private [Boolean] Whether to include private methods 114 | # @return [Boolean] result 115 | def respond_to?(method, include_private = false) 116 | super || object.respond_to?(method, include_private) 117 | end 118 | 119 | # Returns true if `klass` is the class of `object` or the presenter, or 120 | # if `#class` is one of the superclasses of `object`, the presenter or 121 | # modules included in `object` or the presenter. 122 | # 123 | # @param other [Module] 124 | # @return [Boolean] result 125 | def kind_of?(other) 126 | super || object.kind_of?(other) 127 | end 128 | 129 | alias :is_a? :kind_of? 130 | 131 | # Returns `true` if `object` or the presenter is an instance of the given 132 | # `class`. 133 | # 134 | # @param klass [Class] 135 | # @return [Boolean] result 136 | def instance_of?(klass) 137 | super || object.instance_of?(klass) 138 | end 139 | 140 | # Returns `true` if `object` or the presenter tests positive for equality. 141 | # 142 | # @param other [Object] 143 | # @return [Boolean] result 144 | def ==(other) 145 | super || object == other 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oprah 2 | 3 | [![Gem Version](https://badge.fury.io/rb/oprah.svg)](https://badge.fury.io/rb/oprah) 4 | [![Build Status](https://travis-ci.org/endofunky/oprah.svg)](https://travis-ci.org/endofunky/oprah) 5 | [![Code Climate](https://codeclimate.com/github/endofunky/oprah.svg)](https://codeclimate.com/github/endofunky/oprah) 6 | [![Dependency Status](https://gemnasium.com/badges/github.com/endofunky/oprah.svg)](https://gemnasium.com/github.com/endofunky/oprah) 7 | 8 | Opinionated presenters for Rails 5 - without the cruft. 9 | 10 | ## Table of Contents 11 | 12 | * [Overview](#overview) 13 | * [Installation](#installation) 14 | * [Getting started](#getting-started) 15 | + [ActionController integration](#actioncontroller-integration) 16 | * [Collections](#collections) 17 | * [Associations](#associations) 18 | * [Composition](#composition) 19 | + [Performance](#performance) 20 | + [Ordering](#ordering) 21 | * [License](#license) 22 | * [Author](#author) 23 | 24 | ## Overview 25 | 26 | If you've ever worked on a sufficiently large Rails application you've probably 27 | experienced the Rails helper mess first hand. Helper methods are annoying to 28 | locate, hard to test and not terribly expressive. 29 | 30 | So why another presenter/decorator library? Oprah was written with a few simple 31 | goals in mind only covered partially (or not at all) by other gems: 32 | 33 | - Lightweight 34 | - Presenters should be easy to test 35 | - No monkey patching :monkey::gun: 36 | - Embrace convention over configuration 37 | - First-class support for composition (modules and concerns) 38 | 39 | ## Installation 40 | 41 | Add this line to your application's Gemfile: 42 | 43 | ``` ruby 44 | gem 'oprah' 45 | ``` 46 | 47 | And then execute: 48 | 49 | ``` 50 | $ bundle 51 | ``` 52 | 53 | ## Getting started 54 | 55 | Oprah expects a single presenter for each of your classes or modules. If your 56 | model is called `User` it will look for a class called `UserPresenter`: 57 | 58 | ``` ruby 59 | class User 60 | def first_name 61 | "John" 62 | end 63 | 64 | def last_name 65 | "Doe" 66 | end 67 | end 68 | 69 | class UserPresenter < Oprah::Presenter 70 | def name 71 | "#{first_name} #{last_name}" 72 | end 73 | end 74 | ``` 75 | 76 | Oprah will figure out the presenters by itself so you don't have to instantiate 77 | your presenter classes directly: 78 | 79 | ``` ruby 80 | presenter = Oprah.present(User.new) 81 | 82 | presenter.name 83 | # => "John Doe" 84 | 85 | ``` 86 | 87 | Of course, all the regular methods on your model are still accessible: 88 | 89 | ``` ruby 90 | presenter.first_name 91 | # => "John" 92 | ``` 93 | 94 | If you *DO* want to use a specific presenter, you can simply instantiate it 95 | yourself: 96 | 97 | ``` ruby 98 | SomeOtherPresenter.new(User.new) 99 | ``` 100 | 101 | ### ActionController integration 102 | 103 | Now, where do we put our presenters? Ideally, you'd want to expose them in your 104 | controller. Oprah avoids monkey patching and generally it's good to be aware of 105 | what's going on, even if that means to be (at least a little bit) explicit. 106 | 107 | Here's how you can use Oprah presenters from your controller: 108 | 109 | ``` ruby 110 | class UsersController < ApplicationController 111 | def show 112 | @user = present User.find(params[:id]) 113 | end 114 | end 115 | ``` 116 | 117 | This will also take care of passing the correct view context to the presenter, 118 | which you can access with the `#view_context` (or shorter, `#h`) instance 119 | method. 120 | 121 | ## Collections 122 | 123 | Oprah has basic support for collections with `.present_many`. It will simply 124 | apply it's `.present` behavior to each object in the given collection: 125 | 126 | ``` ruby 127 | users = [User.new, User.new] 128 | presenters = Oprah.present_many(users) 129 | 130 | presenters.first.kind_of?(UserPresenter) 131 | # => true 132 | 133 | presenters.last.kind_of?(UserPresenter) 134 | # => true 135 | ``` 136 | 137 | Of course, this works in controllers, too: 138 | 139 | ``` ruby 140 | class UserController < ApplicationController 141 | def index 142 | @users = present_many User.all 143 | end 144 | end 145 | ``` 146 | 147 | ## Associations 148 | 149 | You can also automatically use presenters for your associations using the 150 | `#presents_one` and `#presents_many` macros. Let's say you have the following 151 | `Project` model: 152 | 153 | ``` ruby 154 | class Project 155 | has_many :users 156 | has_one :owner, class_name: "User" 157 | end 158 | ``` 159 | 160 | Oprah lets you easily wrap the associated objects: 161 | 162 | ``` ruby 163 | class ProjectPresenter < Oprah::Presenter 164 | presents_many :users 165 | presents_one :owner 166 | end 167 | ``` 168 | 169 | Note that you don't need to explicitly state the association class. 170 | 171 | ## Composition 172 | 173 | Let's say you extraced some behaviour out of your model into a reusable module (or 174 | `ActiveSupport::Concern`). Oprah lets you write a single, separate presenter for 175 | this module and automatically chains it to your "main presenter" by walking up the 176 | ancestor chain of the given object. 177 | 178 | Let's say we want to mix a shared `Describable` module into our `User` class from 179 | above and render the description to HTML: 180 | 181 | 182 | ``` ruby 183 | module Describable 184 | def description 185 | "*AWESOME*" 186 | end 187 | end 188 | 189 | class User 190 | include Describable 191 | end 192 | 193 | class DescribablePresenter < Oprah::Presenter 194 | def description 195 | Kramdown::Document.new(object.description).to_html 196 | end 197 | end 198 | ``` 199 | 200 | You can now access the methods of both, `UserPresenter` *and* 201 | `DescribablePresenter`: 202 | 203 | ``` ruby 204 | presenter = Oprah.present(User.new) 205 | 206 | presenter.description 207 | => "

AWESOME

\n" 208 | 209 | presenter.name 210 | # => John Doe 211 | ``` 212 | 213 | ### Performance 214 | 215 | Of course, looking up all the presenters would imply a performance issue. But 216 | don't worry, Oprah caches all matching presenters for a class (and busts it's 217 | cache on code reloads for a smooth development experience). 218 | 219 | ### Ordering 220 | 221 | Oprah walks your object's ancestor chain in reverse. For example, you'd be 222 | able to access the methods exposed by the `DescribablePresenter` from your 223 | `UserPresenter`. You can even use `super`: 224 | 225 | ``` ruby 226 | class DescribablePresenter < Oprah::Presenter 227 | def baz 228 | "foo" 229 | end 230 | end 231 | 232 | class UserPresenter < Oprah::Presenter 233 | def baz 234 | super + "bar" 235 | end 236 | end 237 | 238 | Oprah.present(User.new).baz 239 | # => "foobar" 240 | ``` 241 | 242 | ## License 243 | 244 | Released under the MIT license. See the LICENSE file for details. 245 | 246 | ## Author 247 | 248 | Tobias Svensson, [@endofunky](https://twitter.com/endofunky), [http://github.com/endofunky](http://github.com/endofunky) 249 | --------------------------------------------------------------------------------