diff --git a/lib/dry/view/context.rb b/lib/dry/view/context.rb index ab44a467..0577aa30 100644 --- a/lib/dry/view/context.rb +++ b/lib/dry/view/context.rb @@ -1,25 +1,25 @@ +require "dry/equalizer" + module Dry module View class Context - attr_reader :_options, :_part_builder, :_renderer + include Dry::Equalizer(:_options) + + attr_reader :_rendering, :_options - def initialize(part_builder: nil, renderer: nil, **options) - @_part_builder = part_builder - @_renderer = renderer + def initialize(rendering: nil, **options) + @_rendering = rendering @_options = options end - def bind(part_builder:, renderer:) - self.class.new( - **_options.merge( - part_builder: part_builder, - renderer: renderer, - ) - ) + def for_rendering(rendering) + return self if rendering == self._rendering + + self.class.new(**_options.merge(rendering: rendering)) end - def bound? - !!(_part_builder && _renderer) + def rendering? + !!_rendering end def self.decorate(*names, **options) @@ -28,15 +28,11 @@ def self.decorate(*names, **options) define_method name do attribute = super() - return attribute unless bound? || !attribute - - _part_builder.( - name: name, - value: attribute, - renderer: _renderer, - context: self, - **options, - ) + if rendering? && attribute + _rendering.part(name, attribute, **options) + else + attribute + end end end end diff --git a/lib/dry/view/controller.rb b/lib/dry/view/controller.rb index e07d8caf..29f015b3 100644 --- a/lib/dry/view/controller.rb +++ b/lib/dry/view/controller.rb @@ -8,7 +8,8 @@ require_relative 'path' require_relative 'rendered' require_relative 'renderer' -require_relative 'scope' +require_relative 'rendering' +require_relative 'scope_builder' module Dry module View @@ -33,18 +34,21 @@ class Controller end setting :default_context, DEFAULT_CONTEXT + setting :scope + setting :inflector, Dry::Inflector.new setting :part_builder, PartBuilder setting :part_namespace + setting :scope_builder, ScopeBuilder + setting :scope_namespace + attr_reader :config attr_reader :layout_dir attr_reader :layout_path attr_reader :template_path - attr_reader :part_builder - attr_reader :exposures # @api private @@ -60,6 +64,11 @@ def self.paths Array(config.paths).map { |path| Dry::View::Path.new(path) } end + # @api private + def self.rendering(format: config.default_format, context: config.default_context) + Rendering.prepare(renderer(format), config, context) + end + # @api private def self.renderer(format) renderers.fetch(format) { @@ -100,11 +109,6 @@ def initialize @layout_path = "#{layout_dir}/#{config.layout}" @template_path = config.template - @part_builder = config.part_builder.new( - namespace: config.part_namespace, - inflector: config.inflector, - ) - @exposures = self.class.exposures.bind(self) end @@ -112,15 +116,13 @@ def initialize def call(format: config.default_format, context: config.default_context, **input) raise UndefinedTemplateError, "no +template+ configured" unless template_path - renderer = self.class.renderer(format) - context = context.bind(part_builder: part_builder, renderer: renderer) - - locals = locals(renderer.chdir(template_path), context, input) - - output = renderer.template(template_path, template_scope(renderer, context, locals)) + template_rendering = self.class.rendering(format: format, context: context).chdir(template_path) + locals = locals(template_rendering, input) + output = template_rendering.template(template_path, template_rendering.scope(config.scope, locals)) if layout? - output = renderer.template(layout_path, layout_scope(renderer, context, layout_locals(locals))) { output } + layout_rendering = self.class.rendering(format: format, context: context).chdir(layout_path) + output = layout_rendering.template(layout_path, layout_rendering.scope(config.scope, layout_locals(locals))) { output } end Rendered.new(output: output, locals: locals) @@ -128,10 +130,10 @@ def call(format: config.default_format, context: config.default_context, **input private - def locals(renderer, context, input) + def locals(rendering, input) exposures.(input) do |value, exposure| - if exposure.decorate? - decorate_local(renderer, context, exposure.name, value, **exposure.options) + if exposure.decorate? && value + rendering.part(exposure.name, value, **exposure.options) else value end @@ -147,38 +149,6 @@ def layout_locals(locals) def layout? !!config.layout end - - def layout_scope(renderer, context, locals = EMPTY_LOCALS) - scope(renderer.chdir(layout_dir), context, locals) - end - - def template_scope(renderer, context, locals) - scope(renderer.chdir(template_path), context, locals) - end - - def scope(renderer, context, locals = EMPTY_LOCALS) - Scope.new( - renderer: renderer, - context: context, - locals: locals, - ) - end - - def decorate_local(renderer, context, name, value, **options) - if value - # Decorate truthy values only - part_builder.( - name: name, - value: value, - renderer: renderer, - context: context, - namespace: config.part_namespace, - **options, - ) - else - value - end - end end end end diff --git a/lib/dry/view/missing_renderer.rb b/lib/dry/view/missing_renderer.rb deleted file mode 100644 index 86baa866..00000000 --- a/lib/dry/view/missing_renderer.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Dry - module View - class MissingRendererError < StandardError - def initialize(message = "No renderer provided") - super - end - end - - class MissingRenderer - def method_missing(name, *args, &block) - raise MissingRendererError - end - end - end -end diff --git a/lib/dry/view/part.rb b/lib/dry/view/part.rb index 9ed73d94..e4b559fd 100644 --- a/lib/dry/view/part.rb +++ b/lib/dry/view/part.rb @@ -1,6 +1,4 @@ require 'dry-equalizer' -require 'dry/view/scope' -require 'dry/view/missing_renderer' module Dry module View @@ -8,20 +6,17 @@ class Part CONVENIENCE_METHODS = %i[ context render + scope value ].freeze - include Dry::Equalizer(:_name, :_value, :_part_builder, :_context, :_renderer) + include Dry::Equalizer(:_name, :_value, :_rendering) attr_reader :_name attr_reader :_value - attr_reader :_context - - attr_reader :_renderer - - attr_reader :_part_builder + attr_reader :_rendering attr_reader :_decorated_attributes @@ -37,17 +32,20 @@ def self.decorated_attributes @decorated_attributes ||= {} end - def initialize(name:, value:, part_builder: Dry::View::PartBuilder.new, renderer: MissingRenderer.new, context: nil) + def initialize(name:, value:, rendering:) @_name = name @_value = value - @_context = context - @_renderer = renderer - @_part_builder = part_builder + @_rendering = rendering + @_decorated_attributes = {} end def _render(partial_name, as: _name, **locals, &block) - _renderer.partial(partial_name, _render_scope(as, locals), &block) + _rendering.partial(partial_name, _rendering.scope({as => self}.merge(locals)), &block) + end + + def _scope(scope_name = nil, **locals) + _rendering.scope(scope_name, {_name => self}.merge(locals)) end def to_s @@ -58,15 +56,17 @@ def new(klass = (self.class), name: (_name), value: (_value), **options) klass.new( name: name, value: value, - context: _context, - renderer: _renderer, - part_builder: _part_builder, + rendering: _rendering, **options, ) end private + def _context + _rendering.context + end + def method_missing(name, *args, &block) if self.class.decorated_attributes.key?(name) _resolve_decorated_attribute(name) @@ -85,14 +85,6 @@ def respond_to_missing?(name, include_private = false) d.key?(name) || c.include?(name) || _value.respond_to?(name, include_private) || super end - def _render_scope(name, **locals) - Scope.new( - locals: locals.merge(name => self), - context: _context, - renderer: _renderer, - ) - end - def _resolve_decorated_attribute(name) _decorated_attributes.fetch(name) { attribute = _value.__send__(name) @@ -100,13 +92,7 @@ def _resolve_decorated_attribute(name) _decorated_attributes[name] = if attribute # Decorate truthy attributes only - _part_builder.( - name: name, - value: attribute, - renderer: _renderer, - context: _context, - **self.class.decorated_attributes[name], - ) + _rendering.part(name, attribute, **self.class.decorated_attributes[name]) end } end diff --git a/lib/dry/view/part_builder.rb b/lib/dry/view/part_builder.rb index fd56e467..11d00577 100644 --- a/lib/dry/view/part_builder.rb +++ b/lib/dry/view/part_builder.rb @@ -1,40 +1,56 @@ -require 'dry/inflector' +require 'dry/equalizer' require_relative 'part' module Dry module View class PartBuilder + include Dry::Equalizer(:namespace) + attr_reader :namespace - attr_reader :inflector + attr_reader :rendering - def initialize(namespace: nil, inflector: Dry::Inflector.new) + def initialize(namespace: nil, rendering: nil) @namespace = namespace - @inflector = inflector + @rendering = rendering end - def call(name:, value:, renderer:, context:, **options) + def for_rendering(rendering) + return self if rendering == self.rendering + + self.class.new(namespace: namespace, rendering: rendering) + end + + def rendering? + !!rendering + end + + def call(name, value, **options) builder = value.respond_to?(:to_ary) ? :build_collection_part : :build_part - send(builder, name: name, value: value, renderer: renderer, context: context, **options) + send(builder, name, value, **options) end private - def build_part(name:, value:, renderer:, context:, **options) + def build_part(name, value, **options) klass = part_class(name: name, **options) - klass.new(name: name, value: value, part_builder: self, renderer: renderer, context: context) + klass.new( + name: name, + value: value, + rendering: rendering, + ) end - def build_collection_part(name:, value:, renderer:, context:, **options) + def build_collection_part(name, value, **options) collection_as = collection_options(name: name, **options)[:as] item_name, item_as = collection_item_options(name: name, **options).values_at(:name, :as) arr = value.to_ary.map { |obj| - build_part(name: item_name, value: obj, renderer: renderer, context: context, **options.merge(as: item_as)) + build_part(item_name, obj, **options.merge(as: item_as)) } - build_part(name: name, value: arr, renderer: renderer, context: context, **options.merge(as: collection_as)) + build_part(name, arr, **options.merge(as: collection_as)) end def collection_options(name:, **options) @@ -93,6 +109,10 @@ def resolve_part_class(name:, fallback_class:) fallback_class end end + + def inflector + rendering.inflector + end end end end diff --git a/lib/dry/view/renderer.rb b/lib/dry/view/renderer.rb index 3a7aca98..c0e09d88 100644 --- a/lib/dry/view/renderer.rb +++ b/lib/dry/view/renderer.rb @@ -46,7 +46,7 @@ def render(path, scope, &block) def chdir(dirname) new_paths = paths.map { |path| path.chdir(dirname) } - self.class.new(new_paths, format: format) + self.class.new(new_paths, format: format, **options) end def lookup(name) diff --git a/lib/dry/view/rendering.rb b/lib/dry/view/rendering.rb new file mode 100644 index 00000000..4ce5274b --- /dev/null +++ b/lib/dry/view/rendering.rb @@ -0,0 +1,55 @@ +require "dry/equalizer" + +module Dry + module View + class Rendering + def self.prepare(renderer, config, context) + new( + renderer: renderer, + inflector: config.inflector, + context: context, + scope_builder: config.scope_builder.new(namespace: config.scope_namespace), + part_builder: config.part_builder.new(namespace: config.part_namespace), + ) + end + + include Dry::Equalizer(:renderer, :inflector, :context, :scope_builder, :part_builder) + + attr_reader :renderer, :inflector, :context, :scope_builder, :part_builder + + def initialize(renderer:, inflector:, context:, scope_builder:, part_builder:) + @renderer = renderer + @inflector = inflector + @context = context.for_rendering(self) + @scope_builder = scope_builder.for_rendering(self) + @part_builder = part_builder.for_rendering(self) + end + + def part(name, value, **options) + part_builder.(name, value, **options) + end + + def scope(name = nil, locals) + scope_builder.(name, locals) + end + + def template(name, scope, &block) + renderer.template(name, scope, &block) + end + + def partial(name, scope, &block) + renderer.partial(name, scope, &block) + end + + def chdir(dirname) + self.class.new( + renderer: renderer.chdir(dirname), + inflector: inflector, + context: context, + scope_builder: scope_builder, + part_builder: part_builder, + ) + end + end + end +end diff --git a/lib/dry/view/scope.rb b/lib/dry/view/scope.rb index 54f2d0f8..970b5046 100644 --- a/lib/dry/view/scope.rb +++ b/lib/dry/view/scope.rb @@ -1,22 +1,31 @@ -require 'dry-equalizer' +require 'dry/equalizer' +require 'dry/core/constants' module Dry module View class Scope - include Dry::Equalizer(:_locals, :_context, :_renderer) + include Dry::Equalizer(:_name, :_locals, :_rendering) + attr_reader :_name attr_reader :_locals - attr_reader :_context - attr_reader :_renderer + attr_reader :_rendering - def initialize(renderer:, context: nil, locals: {}) + def initialize(name: nil, locals: Dry::Core::Constants::EMPTY_HASH, rendering:) + @_name = name @_locals = locals - @_context = context - @_renderer = renderer + @_rendering = rendering end - def render(partial_name, **locals, &block) - _renderer.partial(partial_name, __render_scope(locals), &block) + def render(partial_name = nil, **locals, &block) + partial_name ||= _name + raise ArgumentError, "+partial_name+ must be provided for unnamed scopes" unless partial_name + partial_name = _inflector.underscore(_inflector.demodulize(partial_name.to_s)) if partial_name.is_a?(Class) + + _rendering.partial(partial_name, _render_scope(locals), &block) + end + + def scope(name = nil, **locals) + _rendering.scope(name, locals) end private @@ -24,20 +33,28 @@ def render(partial_name, **locals, &block) def method_missing(name, *args, &block) if _locals.key?(name) _locals[name] - elsif _context.respond_to?(name) - _context.public_send(name, *args, &block) + elsif _rendering.context.respond_to?(name) + _rendering.context.public_send(name, *args, &block) else super end end - def __render_scope(**locals) - if locals.any? - self.class.new(renderer: _renderer, context: _context, locals: locals) - else + def _render_scope(**locals) + if locals.none? self + else + self.class.new( + # FIXME: what about `name`? + locals: locals, + rendering: _rendering, + ) end end + + def _inflector + _rendering.inflector + end end end end diff --git a/lib/dry/view/scope_builder.rb b/lib/dry/view/scope_builder.rb new file mode 100644 index 00000000..dfc91b5d --- /dev/null +++ b/lib/dry/view/scope_builder.rb @@ -0,0 +1,74 @@ +require 'dry/equalizer' +require_relative 'scope' + +module Dry + module View + class ScopeBuilder + include Dry::Equalizer(:namespace) + + attr_reader :namespace + attr_reader :rendering + + def initialize(namespace: nil, rendering: nil) + @namespace = namespace + @rendering = rendering + end + + def for_rendering(rendering) + return self if rendering == self.rendering + + self.class.new(namespace: namespace, rendering: rendering) + end + + def rendering? + !!rendering + end + + def call(name = nil, locals) + scope_class(name).new( + name: name, + locals: locals, + rendering: rendering, + ) + end + + private + + DEFAULT_SCOPE_CLASS = Scope + + def scope_class(name = nil) + if name.nil? + DEFAULT_SCOPE_CLASS + elsif name.is_a?(Class) + name + else + resolve_scope_class(name: name) + end + end + + def resolve_scope_class(name:) + name = inflector.camelize(name.to_s) + + # Give autoloaders a change to act + begin + klass = namespace.const_get(name) + rescue NameError + end + + if !klass && namespace.const_defined?(name, false) + klass = namespace.const_get(name) + end + + if klass && klass < Scope + klass + else + DEFAULT_SCOPE_CLASS + end + end + + def inflector + rendering.inflector + end + end + end +end diff --git a/spec/fixtures/integration/scopes/anonymous_scope.html.slim b/spec/fixtures/integration/scopes/anonymous_scope.html.slim new file mode 100644 index 00000000..b82a05d3 --- /dev/null +++ b/spec/fixtures/integration/scopes/anonymous_scope.html.slim @@ -0,0 +1 @@ +== scope(greeting: text).render(:greeting) diff --git a/spec/fixtures/integration/scopes/class_named_scope_with_implicit_render.html.slim b/spec/fixtures/integration/scopes/class_named_scope_with_implicit_render.html.slim new file mode 100644 index 00000000..cc0b9809 --- /dev/null +++ b/spec/fixtures/integration/scopes/class_named_scope_with_implicit_render.html.slim @@ -0,0 +1 @@ +== scope(Test::Scopes::Greeting, greeting: text).render diff --git a/spec/fixtures/integration/scopes/custom_view_controller_scope.html.slim b/spec/fixtures/integration/scopes/custom_view_controller_scope.html.slim new file mode 100644 index 00000000..05c215a7 --- /dev/null +++ b/spec/fixtures/integration/scopes/custom_view_controller_scope.html.slim @@ -0,0 +1 @@ +== hello diff --git a/spec/fixtures/integration/scopes/named_scope_with_defaults.html.slim b/spec/fixtures/integration/scopes/named_scope_with_defaults.html.slim new file mode 100644 index 00000000..65e5bff0 --- /dev/null +++ b/spec/fixtures/integration/scopes/named_scope_with_defaults.html.slim @@ -0,0 +1 @@ +== scope(:greeting).render diff --git a/spec/fixtures/integration/scopes/named_scope_with_explicit_render.html.slim b/spec/fixtures/integration/scopes/named_scope_with_explicit_render.html.slim new file mode 100644 index 00000000..a14150e6 --- /dev/null +++ b/spec/fixtures/integration/scopes/named_scope_with_explicit_render.html.slim @@ -0,0 +1 @@ +== scope(:greeting, greeting: text).render(:holler) diff --git a/spec/fixtures/integration/scopes/named_scope_with_implicit_render.html.slim b/spec/fixtures/integration/scopes/named_scope_with_implicit_render.html.slim new file mode 100644 index 00000000..106765e9 --- /dev/null +++ b/spec/fixtures/integration/scopes/named_scope_with_implicit_render.html.slim @@ -0,0 +1 @@ +== scope(:greeting, greeting: text).render diff --git a/spec/fixtures/integration/scopes/scope_from_part.html.slim b/spec/fixtures/integration/scopes/scope_from_part.html.slim new file mode 100644 index 00000000..7e4b9df2 --- /dev/null +++ b/spec/fixtures/integration/scopes/scope_from_part.html.slim @@ -0,0 +1 @@ +== message.greeting diff --git a/spec/fixtures/integration/scopes/shared/_greeting.html.slim b/spec/fixtures/integration/scopes/shared/_greeting.html.slim new file mode 100644 index 00000000..5a2d87af --- /dev/null +++ b/spec/fixtures/integration/scopes/shared/_greeting.html.slim @@ -0,0 +1 @@ +| Greeting: #{greeting} diff --git a/spec/fixtures/integration/scopes/shared/_holler.html.slim b/spec/fixtures/integration/scopes/shared/_holler.html.slim new file mode 100644 index 00000000..3256174f --- /dev/null +++ b/spec/fixtures/integration/scopes/shared/_holler.html.slim @@ -0,0 +1 @@ +| Holler: #{greeting} diff --git a/spec/fixtures/integration/scopes/unnamed_named_scope_with_implicit_render.html.slim b/spec/fixtures/integration/scopes/unnamed_named_scope_with_implicit_render.html.slim new file mode 100644 index 00000000..16f8aa91 --- /dev/null +++ b/spec/fixtures/integration/scopes/unnamed_named_scope_with_implicit_render.html.slim @@ -0,0 +1 @@ +== scope(hello: "world").render diff --git a/spec/integration/controller/locals_spec.rb b/spec/integration/controller/locals_spec.rb index 3cd42be2..1535f1b8 100644 --- a/spec/integration/controller/locals_spec.rb +++ b/spec/integration/controller/locals_spec.rb @@ -2,7 +2,6 @@ require "dry/view/part" RSpec.describe "locals" do - specify "locals are decorated with parts by default" do vc = Class.new(Dry::View::Controller) do configure do |config| diff --git a/spec/integration/part/decorated_attributes_spec.rb b/spec/integration/part/decorated_attributes_spec.rb index 4782532a..2e41878a 100644 --- a/spec/integration/part/decorated_attributes_spec.rb +++ b/spec/integration/part/decorated_attributes_spec.rb @@ -1,4 +1,5 @@ require 'dry/core/inflector' +require 'dry/view/scope_builder' RSpec.describe 'Part / Decorated attributes' do let(:article_class) { @@ -48,13 +49,26 @@ def initialize(author:, body:) ) } + subject(:article_part) { + article_part_class.new( + name: :article, + value: article, + rendering: rendering, + ) + } + + let(:rendering) { + Dry::View::Rendering.new( + renderer: Dry::View::Renderer.new([Dry::View::Path.new(FIXTURES_PATH)], format: :html), + inflector: Dry::Inflector.new, + context: Dry::View::Context.new, + scope_builder: Dry::View::ScopeBuilder.new, + part_builder: part_builder, + ) + } + describe 'using default part builder' do - subject(:article_part) { - article_part_class.new( - name: :article, - value: article, - ) - } + let(:part_builder) { Dry::View::PartBuilder.new } describe 'decorating without options' do describe 'multiple declarations' do @@ -136,18 +150,10 @@ class CommentPart < Dry::View::Part describe 'using custom part builder' do let(:article_part_class) { - Class.new(Dry::View::Part) do - decorate :author - decorate :comments - end - } - - subject(:article_part) { - article_part_class.new( - name: :article, - value: article, - part_builder: part_builder, - ) + Class.new(Dry::View::Part) do + decorate :author + decorate :comments + end } let(:part_builder) { diff --git a/spec/integration/scope_spec.rb b/spec/integration/scope_spec.rb new file mode 100644 index 00000000..1d7782c1 --- /dev/null +++ b/spec/integration/scope_spec.rb @@ -0,0 +1,169 @@ +require "dry/view/part" +require "dry/view/scope" + +RSpec.describe "Scopes" do + let(:base_vc) { + Class.new(Dry::View::Controller) do + configure do |config| + config.paths = FIXTURES_PATH.join("integration/scopes") + end + end + } + + specify "Custom scope for a view controller" do + module Test + class ControllerScope < Dry::View::Scope + def hello + "Hello #{_locals[:text]}!" + end + end + end + + vc = Class.new(base_vc) do + configure do |config| + config.template = "custom_view_controller_scope" + config.scope = Test::ControllerScope + end + + expose :text + end.new + + expect(vc.(text: "world").to_s).to eq "Hello world!" + end + + specify "Rendering a partial via an anonymous scope" do + vc = Class.new(base_vc) do + configure do |config| + config.template = "anonymous_scope" + end + + expose :text + end.new + + expect(vc.(text: "Hello").to_s).to eq "Greeting: Hello" + end + + specify "Rendering a partial implicitly via a custom named scope" do + module Test::Scopes + class Greeting < Dry::View::Scope + def greeting + _locals[:greeting].upcase + "!" + end + end + end + + vc = Class.new(base_vc) do + configure do |config| + config.scope_namespace = Test::Scopes + config.template = "named_scope_with_implicit_render" + end + + expose :text + end.new + + expect(vc.(text: "Hello").to_s).to eq "Greeting: HELLO!" + end + + specify "Rendering a partial implicitly via a custom named scope (provided via a class)" do + module Test::Scopes + class Greeting < Dry::View::Scope + def greeting + _locals[:greeting].upcase + "!" + end + end + end + + vc = Class.new(base_vc) do + configure do |config| + config.scope_namespace = Test::Scopes + config.template = "class_named_scope_with_implicit_render" + end + + expose :text + end.new + + expect(vc.(text: "Hello").to_s).to eq "Greeting: HELLO!" + end + + specify "Raising an error when an unnamed partial cannot be rendered implicitly" do + vc = Class.new(base_vc) do + configure do |config| + config.template = "unnamed_named_scope_with_implicit_render" + end + end.new + + expect { vc.().to_s }.to raise_error ArgumentError, "+partial_name+ must be provided for unnamed scopes" + end + + specify "Rendering a partial explicitly via a custom named scope" do + module Test::Scopes + class Greeting < Dry::View::Scope + def greeting + _locals[:greeting].upcase + "!" + end + end + end + + vc = Class.new(base_vc) do + configure do |config| + config.scope_namespace = Test::Scopes + config.template = "named_scope_with_explicit_render" + end + + expose :text + end.new + + expect(vc.(text: "Hello").to_s).to eq "Holler: HELLO!" + end + + specify "Custom named scope providing defaults for missing locals" do + module Test::Scopes + class Greeting < Dry::View::Scope + def greeting + _locals.fetch(:greeting) { "Howdy" } + end + end + end + + vc = Class.new(base_vc) do + configure do |config| + config.scope_namespace = Test::Scopes + config.template = "named_scope_with_defaults" + end + + expose :text + end.new + + expect(vc.().to_s).to eq "Greeting: Howdy" + end + + specify "Creating a custom scope from a view part" do + module Test::Parts + class Message < Dry::View::Part + def greeting + scope(:greeting, greeting: value[:text]).render + end + end + end + + module Test::Scopes + class Greeting < Dry::View::Scope + def greeting + _locals[:greeting] + "!" + end + end + end + + vc = Class.new(base_vc) do + configure do |config| + config.part_namespace = Test::Parts + config.scope_namespace = Test::Scopes + config.template = "scope_from_part" + end + + expose :message + end.new + + expect(vc.(message: {text: "Hello from a part"}).to_s).to eq "Greeting: Hello from a part!" + end +end diff --git a/spec/unit/context_spec.rb b/spec/unit/context_spec.rb index 0e1658b1..6b6683ba 100644 --- a/spec/unit/context_spec.rb +++ b/spec/unit/context_spec.rb @@ -1,6 +1,7 @@ require "dry/view/context" require "dry/view/part" require "dry/view/part_builder" +require "dry/view/scope_builder" RSpec.describe Dry::View::Context do let(:context_class) { @@ -17,8 +18,18 @@ def initialize(assets:, **options) } let(:assets) { double(:assets) } - let(:renderer) { double(:renderer) } - let(:part_builder) { Dry::View::PartBuilder.new } + + let(:rendering) { + Dry::View::Rendering.new( + inflector: Dry::Inflector.new, + renderer: double(:renderer), + context: Dry::View::Context.new, + part_builder: Dry::View::PartBuilder.new, + scope_builder: Dry::View::ScopeBuilder.new, + ) + } + + # let(:rendering) { double(:rendering) } it "provides a helpful #inspect on the generated decorated attributes module" do expect(context_class.ancestors[0].inspect).to eq "#" @@ -27,7 +38,7 @@ def initialize(assets:, **options) context "unbound" do subject(:context) { context_class.new(assets: assets) } - it { is_expected.not_to be_bound } + it { is_expected.not_to be_rendering } it "returns its attributes" do expect(context.assets).to eql assets @@ -38,13 +49,12 @@ def initialize(assets:, **options) end end - context "bound" do + context "for rendering" do subject(:context) { - context_class.new(assets: assets). - bind(renderer: renderer, part_builder: part_builder) + context_class.new(assets: assets).for_rendering(rendering) } - it { is_expected.to be_bound } + it { is_expected.to be_rendering } it "returns its assets decorated in view parts" do expect(context.assets).to be_a Dry::View::Part diff --git a/spec/unit/controller_spec.rb b/spec/unit/controller_spec.rb index 4468f2e1..0be38b14 100644 --- a/spec/unit/controller_spec.rb +++ b/spec/unit/controller_spec.rb @@ -49,9 +49,7 @@ def title configure do |config| config.paths = SPEC_ROOT.join('fixtures/templates') config.template = 'controller_renderer_options' - config.renderer_options = { - outvar: '@__buf__' - } + config.renderer_options = {outvar: '@__buf__'} end end.new } @@ -82,10 +80,9 @@ def form(action:, &blk) end.new } - it 'uses default encoding' do - klass = Class.new(Dry::View::Controller) - expect(klass.config.renderer_options).to be_a Hash - expect(klass.config.renderer_options[:default_encoding]).to eql 'utf-8' + it 'merges configured options with default encoding' do + expect(controller.class.config.renderer_options[:outvar]).to eq '@__buf__' + expect(controller.class.config.renderer_options[:default_encoding]).to eq 'utf-8' end it 'are passed to renderer' do diff --git a/spec/unit/part_builder_spec.rb b/spec/unit/part_builder_spec.rb index 1d4d3e47..dc4879eb 100644 --- a/spec/unit/part_builder_spec.rb +++ b/spec/unit/part_builder_spec.rb @@ -1,15 +1,27 @@ +require 'dry/view/scope_builder' + RSpec.describe Dry::View::PartBuilder do - subject(:part_builder) { described_class.new(namespace: namespace) } + subject(:part_builder) { rendering.part_builder } + + let(:rendering) { + Dry::View::Rendering.new( + renderer: Dry::View::Renderer.new([FIXTURES_PATH], format: :html), + inflector: Dry::Inflector.new, + context: Dry::View::Context.new, + scope_builder: Dry::View::ScopeBuilder.new, + part_builder: Dry::View::PartBuilder.new(namespace: namespace) + ) + } let(:namespace) { nil } describe '#call' do - subject(:part) { part_builder.(name: name, value: value, renderer: renderer, context: context, **options) } + subject(:part) { + part_builder.(name, value, **options) + } let(:name) { :user } let(:value) { double(:user) } - let(:renderer) { double(:renderer) } - let(:context) { double(:context) } let(:options) { {} } shared_examples 'a view part' do @@ -23,8 +35,8 @@ expect(part._value).to eq value end - it 'retains the part builder' do - expect(part._part_builder).to eql part_builder + it 'retains the rendering' do + expect(part._rendering).to eql rendering end end diff --git a/spec/unit/part_spec.rb b/spec/unit/part_spec.rb index a5da4a6c..47035b1b 100644 --- a/spec/unit/part_spec.rb +++ b/spec/unit/part_spec.rb @@ -1,32 +1,48 @@ -RSpec::Matchers.define :template_scope do |locals| +require 'dry/view/scope_builder' + +RSpec::Matchers.define :scope do |locals| match do |actual| - locals == locals.map { |k,v| [k, actual.send(k)] }.to_h + locals == actual._locals end end RSpec.describe Dry::View::Part do - context 'with a renderer' do - subject(:part) { described_class.new(name: name, value: value, renderer: renderer, context: context) } + let(:name) { :user } + let(:value) { double(:value) } + let(:rendering) { + Dry::View::Rendering.new( + renderer: renderer, + inflector: Dry::Inflector.new, + context: Dry::View::Context.new, + scope_builder: Dry::View::ScopeBuilder.new, + part_builder: Dry::View::ScopeBuilder.new, + ) + } + let(:renderer) { spy(:renderer) } - let(:name) { :user } - let(:value) { double(:value) } - let(:context) { double(:context) } - let(:renderer) { spy(:renderer) } + context 'with a renderer' do + subject(:part) { + described_class.new( + name: name, + value: value, + rendering: rendering, + ) + } describe '#render' do it 'renders a partial with the part available in its scope' do part.render(:info) - expect(renderer).to have_received(:partial).with(:info, template_scope(user: part)) + expect(renderer).to have_received(:partial).with(:info, scope(user: part)) end it 'allows the part to be made available on a different name' do part.render(:info, as: :admin) - expect(renderer).to have_received(:partial).with(:info, template_scope(admin: part)) + expect(renderer).to have_received(:partial).with(:info, scope(admin: part)) end it 'includes extra locals in the scope' do part.render(:info, extra_local: "hello") - expect(renderer).to have_received(:partial).with(:info, template_scope(user: part, extra_local: "hello")) + expect(renderer).to have_received(:partial).with(:info, scope(user: part, extra_local: "hello")) end end @@ -41,12 +57,9 @@ end describe '#new' do - it 'preserves renderer, and context, and part_builder' do + it 'preserves rendering' do new_part = part.new(value: 'new value') - - expect(new_part._renderer).to eql part._renderer - expect(new_part._context).to eql part._context - expect(new_part._part_builder).to eql part._part_builder + expect(new_part._rendering).to eql part._rendering end end @@ -83,22 +96,4 @@ end end end - - context 'without a renderer' do - subject(:part) { described_class.new(name: name, value: value, context: context) } - - let(:name) { :user } - let(:value) { double('value') } - let(:context) { double('context') } - - describe '#initialize' do - it 'can be initialized' do - expect(part).to be_an_instance_of(Dry::View::Part) - end - - it 'raises an exception when render is called' do - expect { part.render(:info) }.to raise_error(Dry::View::MissingRendererError).with_message('No renderer provided') - end - end - end end diff --git a/spec/unit/rendering_spec.rb b/spec/unit/rendering_spec.rb new file mode 100644 index 00000000..5097029e --- /dev/null +++ b/spec/unit/rendering_spec.rb @@ -0,0 +1,26 @@ +require "dry/view/rendering" + +require "dry/inflector" +require "dry/view/context" +require "dry/view/part_builder" +require "dry/view/scope_builder" + +RSpec.describe Dry::View::Rendering do + subject(:rendering) { described_class.new(**rendering_options) } + + let(:rendering_options) { + { + inflector: Dry::Inflector.new, + renderer: Dry::View::Renderer.new([Dry::View::Path.new(FIXTURES_PATH)], format: :html), + context: Dry::View::Context.new, + part_builder: Dry::View::PartBuilder.new, + scope_builder: Dry::View::ScopeBuilder.new, + } + } + + describe "#==" do + it "is equal when its options are equal" do + expect(rendering).to eq described_class.new(**rendering_options) + end + end +end diff --git a/spec/unit/scope_spec.rb b/spec/unit/scope_spec.rb index 6fc93f98..a759da31 100644 --- a/spec/unit/scope_spec.rb +++ b/spec/unit/scope_spec.rb @@ -1,28 +1,39 @@ +require 'dry/view/scope_builder' + RSpec.describe Dry::View::Scope do - subject(:scope) { described_class.new(renderer: renderer, context: context, locals: locals) } + subject(:scope) { + described_class.new( + locals: locals, + rendering: rendering, + ) + } let(:locals) { {} } + let(:rendering) { spy(:rendering, context: context) } let(:context) { double(:context) } - let(:renderer) { spy(:renderer) } describe '#render' do it 'renders a partial with itself as the scope' do scope.render(:info) - expect(renderer).to have_received(:partial).with(:info, scope) + expect(rendering).to have_received(:partial).with(:info, scope) end it 'renders a partial with provided locals' do - scope_with_locals = described_class.new(renderer: renderer, context: context, locals: {foo: 'bar'}) + scope_with_locals = described_class.new( + locals: {foo: 'bar'}, + rendering: rendering, + ) scope.render(:info, foo: 'bar') - expect(renderer).to have_received(:partial).with(:info, scope_with_locals) + + expect(rendering).to have_received(:partial).with(:info, scope_with_locals) end end describe '#method_missing' do context 'matching locals' do let(:locals) { {greeting: 'hello from locals'} } - let(:context) { double('context', greeting: 'hello from context') } + let(:context) { double(:context, greeting: 'hello from context') } it 'returns a matching value from the locals, in favour of a matching method on the context' do expect(scope.greeting).to eq 'hello from locals' @@ -30,7 +41,7 @@ end context 'matching context' do - let(:context) { double('context', greeting: 'hello from context') } + let(:context) { double(:context, greeting: 'hello from context') } it 'calls the matching method on the context' do expect(scope.greeting).to eq 'hello from context'