Restore context. Collections inherit context by default, but associations

can specify either a static context or a lambda.  Validation for options
in a number of methods.
This commit is contained in:
Toby Ovod-Everett 2012-12-12 17:52:12 -09:00 committed by Andrew Haines
parent 059ecbcc89
commit b2f63882ec
9 changed files with 471 additions and 69 deletions

View File

@ -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}.")

View File

@ -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
"#<CollectionDecorator of #{decorator_class} for #{source.inspect}>"
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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