diff --git a/lib/draper.rb b/lib/draper.rb index e241c3d..bea162b 100644 --- a/lib/draper.rb +++ b/lib/draper.rb @@ -38,6 +38,13 @@ module Draper end end + def self.validate_options(options, *valid_keys) + options_errors = options.keys - valid_keys + unless options_errors.empty? + raise ArgumentError, "Invalid option keys: #{options_errors.map {|k| k.inspect}.join(', ')}", caller + end + end + class UninferrableDecoratorError < NameError def initialize(klass) super("Could not infer a decorator for #{klass}.") diff --git a/lib/draper/collection_decorator.rb b/lib/draper/collection_decorator.rb index 9b21b52..e3822e6 100644 --- a/lib/draper/collection_decorator.rb +++ b/lib/draper/collection_decorator.rb @@ -1,21 +1,25 @@ +require 'draper' + module Draper class CollectionDecorator include Enumerable include ViewHelpers attr_accessor :source, :options, :decorator_class + protected :options, :options= alias_method :to_source, :source delegate :as_json, *(Array.instance_methods - Object.instance_methods), to: :decorated_collection # @param source collection to decorate - # @param options [Hash] passed to each item's decorator (except - # for the keys listed below) - # @option options [Class,Symbol] :with the class used to decorate + # @param [Hash] options (optional) + # @option options [Class, Symbol] :with the class used to decorate # items, or `:infer` to call each item's `decorate` method instead + # @option options [Hash] :context context available to each item's decorator def initialize(source, options = {}) @source = source @decorator_class = options.delete(:with) || self.class.inferred_decorator_class + Draper.validate_options(options, :with, :context) @options = options end @@ -56,18 +60,24 @@ module Draper "#" end - def options=(options) - each {|item| item.options = options } - @options = options + # Accessor for `:context` option + def context + options.fetch(:context, {}) + end + + # Setter for `:context` option + def context=(input) + options[:context] = input + each {|item| item.context = input } unless respond_to?(:loaded?) && !loaded? end protected def decorate_item(item) if decorator_class == :infer - item.decorate(options) + item.decorate(context: context) else - decorator_class.decorate(item, options) + decorator_class.decorate(item, context: context) end end diff --git a/lib/draper/decorated_association.rb b/lib/draper/decorated_association.rb index f1771fa..000f77e 100644 --- a/lib/draper/decorated_association.rb +++ b/lib/draper/decorated_association.rb @@ -1,11 +1,14 @@ +require 'draper' + module Draper class DecoratedAssociation - attr_reader :source, :association, :options + attr_reader :base, :association, :options - def initialize(source, association, options) - @source = source + def initialize(base, association, options) + @base = base @association = association + Draper.validate_options(options, :with, :scope, :context) @options = options end @@ -14,6 +17,10 @@ module Draper decorate end + def source + base.source + end + private def undecorated @@ -25,7 +32,7 @@ module Draper end def decorate - @decorated ||= decorator_class.send(decorate_method, undecorated, options) + @decorated ||= decorator_class.send(decorate_method, undecorated, decorator_options) end def decorate_method @@ -51,5 +58,15 @@ module Draper end end + def decorator_options + decorator_class # Ensures options[:with] = :infer for unspecified collections + + dec_options = collection? ? options.slice(:with, :context) : options.slice(:context) + dec_options[:context] = base.context unless dec_options.key?(:context) + if dec_options[:context].respond_to?(:call) + dec_options[:context] = dec_options[:context].call(base.context) + end + dec_options + end end end diff --git a/lib/draper/decorator.rb b/lib/draper/decorator.rb index 4a31f39..984b074 100755 --- a/lib/draper/decorator.rb +++ b/lib/draper/decorator.rb @@ -1,4 +1,5 @@ require 'active_support/core_ext/array/extract_options' +require 'draper' module Draper class Decorator @@ -6,13 +7,15 @@ module Draper include ActiveModel::Serialization if defined?(ActiveModel::Serialization) attr_accessor :source, :options + protected :options, :options= alias_method :model, :source alias_method :to_source, :source # Initialize a new decorator instance by passing in # an instance of the source class. Pass in an optional - # context inside the options hash is stored for later use. + # :context inside the options hash which is available + # for later use. # # A decorator cannot be applied to other instances of the # same decorator and will instead result in a decorator @@ -23,9 +26,11 @@ module Draper # # @param [Object] source object to decorate # @param [Hash] options (optional) + # @option options [Hash] :context context available to the decorator def initialize(source, options = {}) source.to_a if source.respond_to?(:to_a) # forces evaluation of a lazy query from AR @source = source + Draper.validate_options(options, :context) @options = options handle_multiple_decoration if source.is_a?(Draper::Decorator) end @@ -72,9 +77,15 @@ module Draper # @param [Symbol] association name of association to decorate, like `:products` # @option options [Class] :with the decorator to apply to the association # @option options [Symbol] :scope a scope to apply when fetching the association + # @option options [Hash, #call] :context context available to decorated + # objects in collection. Passing a `lambda` or similar will result in that + # block being called when the association is evaluated. The block will be + # passed the base decorator's `context` Hash and should return the desired + # context Hash for the decorated items. def self.decorates_association(association, options = {}) + Draper.validate_options(options, :with, :scope, :context) define_method(association) do - decorated_associations[association] ||= Draper::DecoratedAssociation.new(source, association, options) + decorated_associations[association] ||= Draper::DecoratedAssociation.new(self, association, options) decorated_associations[association].call end end @@ -82,7 +93,8 @@ module Draper # A convenience method for decorating multiple associations. Calls # decorates_association on each of the given symbols. # - # @param [Symbols*] associations name of associations to decorate + # @param [Symbols*] associations names of associations to decorate + # @param [Hash] options passed to `decorate_association` def self.decorates_associations(*associations) options = associations.extract_options! associations.each do |association| @@ -128,7 +140,9 @@ module Draper # for the keys listed below) # @option options [Class,Symbol] :with (self) the class used to decorate # items, or `:infer` to call each item's `decorate` method instead + # @option options [Hash] :context context available to decorated items def self.decorate_collection(source, options = {}) + Draper.validate_options(options, :with, :context) Draper::CollectionDecorator.new(source, options.reverse_merge(with: self)) end @@ -171,6 +185,16 @@ module Draper source.present? end + # Accessor for `:context` option + def context + options.fetch(:context, {}) + end + + # Setter for `:context` option + def context=(input) + options[:context] = input + end + # For ActiveModel compatibilty def to_model self diff --git a/spec/draper/collection_decorator_spec.rb b/spec/draper/collection_decorator_spec.rb index 09ebd9e..88b6533 100644 --- a/spec/draper/collection_decorator_spec.rb +++ b/spec/draper/collection_decorator_spec.rb @@ -16,33 +16,97 @@ describe Draper::CollectionDecorator do subject.map{|item| item.source}.should == source end - context "with options" do - subject { Draper::CollectionDecorator.new(source, with: ProductDecorator, some: "options") } + context "with context" do + subject { Draper::CollectionDecorator.new(source, with: ProductDecorator, context: {some: 'context'}) } - its(:options) { should == {some: "options"} } + its(:context) { should == {some: 'context'} } - it "passes options to the individual decorators" do + it "passes context to the individual decorators" do subject.each do |item| - item.options.should == {some: "options"} + item.context.should == {some: 'context'} end end - describe "#options=" do - it "updates the options on the collection decorator" do - subject.options = {other: "options"} - subject.options.should == {other: "options"} + it "does not tie the individual decorators' contexts together" do + subject.each do |item| + item.context.should == {some: 'context'} + item.context = {alt: 'context'} + item.context.should == {alt: 'context'} + end + end + + describe "#context=" do + context "with loaded? unimplemented" do + it "updates the context on the collection decorator" do + subject.context = {other: 'context'} + subject.context.should == {other: 'context'} + end + + it "updates the context on the individual decorators" do + subject.context = {other: 'context'} + subject.each do |item| + item.context.should == {other: 'context'} + end + end + + it "updates the context on the individual decorators following modification" do + subject.each do |item| + item.context = {alt: 'context'} + end + subject.context = {other: 'context'} + subject.each do |item| + item.context.should == {other: 'context'} + end + end end - it "updates the options on the individual decorators" do - subject.options = {other: "options"} - subject.each do |item| - item.options.should == {other: "options"} + # We have to stub out loaded? because the test environment uses an Array, + # not an ActiveRecord::Associations::CollectionProxy + context "with loaded? true" do + before(:each) { subject.stub(:loaded?).and_return(true) } + + it "updates the context on the individual decorators following modification" do + subject.each do |item| + item.context = {alt: 'context'} + end + subject.context = {other: 'context'} + subject.each do |item| + item.context.should == {other: 'context'} + end + end + end + + context "with loaded? false" do + before(:each) { subject.stub(:loaded?).and_return(false) } + + it "does not trigger enumeration prematurely" do + subject.should_not_receive(:each) + subject.context = {other: 'context'} + end + + it "the individual decorators still get context upon enumeration" do + subject.context = {other: 'context'} + subject.each do |item| + item.context.should == {other: 'context'} + end end end end end describe "#initialize" do + describe "options validation" do + let(:valid_options) { {with: ProductDecorator, context: {}} } + + it "does not raise error on valid options" do + expect { Draper::CollectionDecorator.new(source, valid_options) }.to_not raise_error + end + + it "raises error on invalid options" do + expect { Draper::CollectionDecorator.new(source, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Invalid option keys: :foo') + end + end + context "when the :with option is given" do context "and the decorator can't be inferred from the class" do subject { Draper::CollectionDecorator.new(source, with: ProductDecorator) } @@ -88,6 +152,29 @@ describe Draper::CollectionDecorator do end end + describe "#options" do + subject { Draper::CollectionDecorator.new(source, with: ProductDecorator, context: {some: 'context'}) } + + it "stores options internally" do + subject.send(:options).should == {context: {some: 'context'}} + end + + it "blocks options externally" do + expect { subject.options }.to raise_error(NoMethodError) + end + end + + describe "#options=" do + it "permits modification of options internally" do + subject.send(:options=, {context: {some: 'other_context'}}) + subject.send(:options).should == {context: {some: 'other_context'}} + end + + it "blocks options= externally" do + expect { subject.options = {context: {some: 'other_context'}} }.to raise_error(NoMethodError) + end + end + describe "#find" do context "with a block" do it "decorates Enumerable#find" do @@ -123,14 +210,14 @@ describe Draper::CollectionDecorator do end describe "#localize" do - before { subject.helpers.should_receive(:localize).with(:an_object, {some: "options"}) } + before { subject.helpers.should_receive(:localize).with(:an_object, {some: 'parameter'}) } it "delegates to helpers" do - subject.localize(:an_object, some: "options") + subject.localize(:an_object, some: 'parameter') end it "is aliased to #l" do - subject.l(:an_object, some: "options") + subject.l(:an_object, some: 'parameter') end end diff --git a/spec/draper/decoratable_spec.rb b/spec/draper/decoratable_spec.rb index cd063e8..efc3140 100644 --- a/spec/draper/decoratable_spec.rb +++ b/spec/draper/decoratable_spec.rb @@ -9,9 +9,9 @@ describe Draper::Decoratable do subject.decorate.source.should be subject end - it "accepts options" do - decorator = subject.decorate(some: "options") - decorator.options.should == {some: "options"} + it "accepts context" do + decorator = subject.decorate(context: {some: 'context'}) + decorator.context.should == {some: 'context'} end it "is not memoized" do @@ -153,9 +153,9 @@ describe Draper::Decoratable do decorator.source.should be Product.scoped end - it "accepts options" do - decorator = Product.decorate(some: "options") - decorator.options.should == {some: "options"} + it "accepts context" do + decorator = Product.decorate(context: {some: 'context'}) + decorator.context.should == {some: 'context'} end it "is not memoized" do diff --git a/spec/draper/decorated_association_spec.rb b/spec/draper/decorated_association_spec.rb index 46471e2..e44aa82 100644 --- a/spec/draper/decorated_association_spec.rb +++ b/spec/draper/decorated_association_spec.rb @@ -1,10 +1,52 @@ require 'spec_helper' describe Draper::DecoratedAssociation do - let(:decorated_association) { Draper::DecoratedAssociation.new(source, association, options) } + let(:decorated_association) { Draper::DecoratedAssociation.new(base, association, options) } let(:source) { Product.new } + let(:base) { source.decorate } let(:options) { {} } + describe "#initialize" do + describe "options validation" do + let(:association) { :similar_products } + let(:valid_options) { {with: ProductDecorator, scope: :foo, context: {}} } + + it "does not raise error on valid options" do + expect { Draper::DecoratedAssociation.new(base, association, valid_options) }.to_not raise_error + end + + it "raises error on invalid options" do + expect { Draper::DecoratedAssociation.new(base, association, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Invalid option keys: :foo') + end + end + end + + describe "#base" do + subject { decorated_association.base } + let(:association) { :similar_products } + + it "returns the base decorator" do + should be base + end + + it "returns a Decorator" do + subject.class.should == ProductDecorator + end + end + + describe "#source" do + subject { decorated_association.source } + let(:association) { :similar_products } + + it "returns the base decorator's source" do + should be base.source + end + + it "returns a Model" do + subject.class.should == Product + end + end + describe "#call" do subject { decorated_association.call } @@ -128,5 +170,144 @@ describe Draper::DecoratedAssociation do subject.source.should be scoped end end + + context "base has context" do + let(:association) { :similar_products } + let(:base) { source.decorate(context: {some: 'context'}) } + + context "when no context is specified" do + it "it should inherit context from base" do + subject.context.should == {some: 'context'} + end + + it "it should share context hash with base" do + subject.context.should be base.context + end + end + + context "when static context is specified" do + let(:options) { {context: {other: 'context'}} } + + it "it should get context from static option" do + subject.context.should == {other: 'context'} + end + end + + context "when lambda context is specified" do + let(:options) { {context: lambda {|context| context.merge(other: 'protext')}} } + + it "it should get generated context" do + subject.context.should == {some: 'context', other: 'protext'} + end + end + end + end + + describe "#decorator_options" do + subject { decorated_association.send(:decorator_options) } + + context "collection association" do + let(:association) { :similar_products } + + context "no options" do + it "should return default options" do + should == {with: :infer, context: {}} + end + + it "should set with: to :infer" do + decorated_association.send(:options).should == options + subject + decorated_association.send(:options).should == {with: :infer} + end + end + + context "option with: ProductDecorator" do + let(:options) { {with: ProductDecorator} } + it "should pass with: from options" do + should == {with: ProductDecorator, context: {}} + end + end + + context "option scope: :to_a" do + let(:options) { {scope: :to_a} } + it "should strip scope: from options" do + decorated_association.send(:options).should == options + should == {with: :infer, context: {}} + end + end + + context "base has context" do + let(:base) { source.decorate(context: {some: 'context'}) } + + context "no options" do + it "should return context from base" do + should == {with: :infer, context: {some: 'context'}} + end + end + + context "option context: {other: 'context'}" do + let(:options) { {context: {other: 'context'}} } + it "should return specified context" do + should == {with: :infer, context: {other: 'context'}} + end + end + + context "option context: lambda" do + let(:options) { {context: lambda {|context| context.merge(other: 'protext')}} } + it "should return specified context" do + should == {with: :infer, context: {some: 'context', other: 'protext'}} + end + end + end + end + + context "singular association" do + let(:association) { :previous_version } + + context "no options" do + it "should return default options" do + should == {context: {}} + end + end + + context "option with: ProductDecorator" do + let(:options) { {with: ProductDecorator} } + it "should strip with: from options" do + should == {context: {}} + end + end + + context "option scope: :decorate" do + let(:options) { {scope: :decorate} } + it "should strip scope: from options" do + decorated_association.send(:options).should == options + should == {context: {}} + end + end + + context "base has context" do + let(:base) { source.decorate(context: {some: 'context'}) } + + context "no options" do + it "should return context from base" do + should == {context: {some: 'context'}} + end + end + + context "option context: {other: 'context'}" do + let(:options) { {context: {other: 'context'}} } + it "should return specified context" do + should == {context: {other: 'context'}} + end + end + + context "option context: lambda" do + let(:options) { {context: lambda {|context| context.merge(other: 'protext')}} } + it "should return specified context" do + should == {context: {some: 'context', other: 'protext'}} + end + end + end + end end end diff --git a/spec/draper/decorator_spec.rb b/spec/draper/decorator_spec.rb index 6401571..68298a3 100755 --- a/spec/draper/decorator_spec.rb +++ b/spec/draper/decorator_spec.rb @@ -7,13 +7,30 @@ describe Draper::Decorator do let(:source) { Product.new } describe "#initialize" do + describe "options validation" do + let(:valid_options) { {context: {}} } + + it "does not raise error on valid options" do + expect { decorator_class.new(source, valid_options) }.to_not raise_error + end + + it "raises error on invalid options" do + expect { decorator_class.new(source, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Invalid option keys: :foo') + end + end + it "sets the source" do subject.source.should be source end - it "stores options" do - decorator = decorator_class.new(source, some: "options") - decorator.options.should == {some: "options"} + it "stores options internally" do + decorator = decorator_class.new(source, context: {some: 'context'}) + decorator.send(:options).should == {context: {some: 'context'}} + end + + it "stores context" do + decorator = decorator_class.new(source, context: {some: 'context'}) + decorator.context.should == {some: 'context'} end context "when decorating an instance of itself" do @@ -24,15 +41,15 @@ describe Draper::Decorator do context "when options are supplied" do it "overwrites existing options" do - decorator = ProductDecorator.new(source, role: :admin) - ProductDecorator.new(decorator, role: :user).options.should == {role: :user} + decorator = ProductDecorator.new(source, context: {role: :admin}) + ProductDecorator.new(decorator, context: {role: :user}).send(:options).should == {context: {role: :user}} end end context "when no options are supplied" do it "preserves existing options" do - decorator = ProductDecorator.new(source, role: :admin) - ProductDecorator.new(decorator).options.should == {role: :admin} + decorator = ProductDecorator.new(source, context: {role: :admin}) + ProductDecorator.new(decorator).send(:options).should == {context: {role: :admin}} end end end @@ -55,10 +72,51 @@ describe Draper::Decorator do end end + describe "#options" do + it "blocks options externally" do + decorator = decorator_class.new(source, context: {some: 'context'}) + expect { decorator.options }.to raise_error(NoMethodError) + end + end + + describe "#options=" do + it "permits modification of options internally" do + decorator = decorator_class.new(source, context: {some: 'context'}) + decorator.send(:options=, {context: {some: 'other_context'}}) + decorator.send(:options).should == {context: {some: 'other_context'}} + end + + it "blocks options= externally" do + decorator = decorator_class.new(source, context: {some: 'context'}) + expect { decorator.options = {context: {some: 'other_context'}} }.to raise_error(NoMethodError) + end + end + + describe "#context=" do + it "permits modification of context" do + decorator = decorator_class.new(source, context: {some: 'context'}) + decorator.context = {some: 'other_context'} + decorator.context.should == {some: 'other_context'} + end + end + describe ".decorate_collection" do subject { ProductDecorator.decorate_collection(source) } let(:source) { [Product.new, Widget.new] } + describe "options validation" do + let(:valid_options) { {with: :infer, context: {}} } + before(:each) { Draper::CollectionDecorator.stub(:new) } + + it "does not raise error on valid options" do + expect { ProductDecorator.decorate_collection(source, valid_options) }.to_not raise_error + end + + it "raises error on invalid options" do + expect { ProductDecorator.decorate_collection(source, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Invalid option keys: :foo') + end + end + it "returns a collection decorator" do subject.should be_a Draper::CollectionDecorator subject.source.should be source @@ -77,11 +135,11 @@ describe Draper::Decorator do end end - context "with options" do - subject { ProductDecorator.decorate_collection(source, with: :infer, some: "options") } + context "with context" do + subject { ProductDecorator.decorate_collection(source, with: :infer, context: {some: 'context'}) } - it "passes the options to the collection decorator" do - subject.options.should == {some: "options"} + it "passes the context to the collection decorator" do + subject.context.should == {some: 'context'} end end end @@ -104,14 +162,14 @@ describe Draper::Decorator do end describe "#localize" do - before { subject.helpers.should_receive(:localize).with(:an_object, {some: "options"}) } + before { subject.helpers.should_receive(:localize).with(:an_object, {some: 'parameter'}) } it "delegates to #helpers" do - subject.localize(:an_object, some: "options") + subject.localize(:an_object, some: 'parameter') end it "is aliased to #l" do - subject.l(:an_object, some: "options") + subject.l(:an_object, some: 'parameter') end end @@ -223,8 +281,26 @@ describe Draper::Decorator do describe "overridden association method" do let(:decorated_association) { ->{} } + describe "options validation" do + let(:valid_options) { {with: ProductDecorator, scope: :foo, context: {}} } + before(:each) { Draper::DecoratedAssociation.stub(:new).and_return(decorated_association) } + + it "does not raise error on valid options" do + expect { decorator_class.decorates_association :similar_products, valid_options }.to_not raise_error + end + + it "raises error on invalid options" do + expect { decorator_class.decorates_association :similar_products, valid_options.merge(foo: 'bar') }.to raise_error(ArgumentError, 'Invalid option keys: :foo') + end + end + it "creates a DecoratedAssociation" do - Draper::DecoratedAssociation.should_receive(:new).with(source, :similar_products, {with: ProductDecorator}).and_return(decorated_association) + Draper::DecoratedAssociation.should_receive(:new).with(subject, :similar_products, {with: ProductDecorator}).and_return(decorated_association) + subject.similar_products + end + + it "receives the Decorator" do + Draper::DecoratedAssociation.should_receive(:new).with(kind_of(decorator_class), :similar_products, {with: ProductDecorator}).and_return(decorated_association) subject.similar_products end diff --git a/spec/draper/finders_spec.rb b/spec/draper/finders_spec.rb index 90d5d44..63db8ae 100644 --- a/spec/draper/finders_spec.rb +++ b/spec/draper/finders_spec.rb @@ -15,9 +15,9 @@ describe Draper::Finders do decorator.source.should be found end - it "passes options to the decorator" do - decorator = ProductDecorator.find(1, some: "options") - decorator.options.should == {some: "options"} + it "passes context to the decorator" do + decorator = ProductDecorator.find(1, context: {some: 'context'}) + decorator.context.should == {some: 'context'} end end @@ -55,10 +55,10 @@ describe Draper::Finders do ProductDecorator.find_or_create_by_name_and_size("apples", "large") end - it "passes options to the decorator" do - Product.should_receive(:find_by_name_and_size).with("apples", "large", {some: "options"}) - decorator = ProductDecorator.find_by_name_and_size("apples", "large", some: "options") - decorator.options.should == {some: "options"} + it "passes context to the decorator" do + Product.should_receive(:find_by_name_and_size).with("apples", "large", context: {some: 'context'}) + decorator = ProductDecorator.find_by_name_and_size("apples", "large", context: {some: 'context'}) + decorator.context.should == {some: 'context'} end end @@ -84,9 +84,9 @@ describe Draper::Finders do collection.first.should be_a ProductDecorator end - it "passes options to the collection decorator" do - collection = ProductDecorator.all(some: "options") - collection.options.should == {some: "options"} + it "passes context to the collection decorator" do + collection = ProductDecorator.all(context: {some: 'context'}) + collection.context.should == {some: 'context'} end end @@ -104,9 +104,9 @@ describe Draper::Finders do decorator.source.should be first end - it "passes options to the decorator" do - decorator = ProductDecorator.first(some: "options") - decorator.options.should == {some: "options"} + it "passes context to the decorator" do + decorator = ProductDecorator.first(context: {some: 'context'}) + decorator.context.should == {some: 'context'} end end @@ -124,9 +124,9 @@ describe Draper::Finders do decorator.source.should be last end - it "passes options to the decorator" do - decorator = ProductDecorator.last(some: "options") - decorator.options.should == {some: "options"} + it "passes context to the decorator" do + decorator = ProductDecorator.last(context: {some: 'context'}) + decorator.context.should == {some: 'context'} end end