Integrate seamlessly with Hanami applications (#173)

This commit is contained in:
Tim Riley 2020-05-18 21:20:36 +10:00 committed by GitHub
parent c988b2dc2b
commit 622c0dd3e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 595 additions and 295 deletions

View File

@ -2,6 +2,8 @@
source "https://rubygems.org"
git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
eval_gemfile "Gemfile.devtools"
gemspec
@ -12,12 +14,14 @@ group :tools do
end
group :test do
gem "rack", ">= 2.0.6"
gem "dry-inflector"
gem "erbse", "~> 0.1.4"
gem "erubi"
gem "hamlit"
gem "hamlit-block"
gem "hanami", github: "hanami/hanami", branch: "unstable"
gem "hanami-devtools", github: "hanami/devtools"
gem "rack", ">= 2.0.6"
gem "slim", "~> 4.0"
end

View File

@ -13,7 +13,3 @@ group :test do
gem "warning"
end
group :tools do
# this is the same version that we use on codacy
gem "rubocop", "0.71.0"
end

View File

@ -5,6 +5,7 @@ require "dry/core/cache"
require "dry/equalizer"
require "dry/inflector"
require_relative "view/application_view"
require_relative "view/context"
require_relative "view/exposures"
require_relative "view/errors"
@ -14,6 +15,7 @@ require_relative "view/render_environment"
require_relative "view/rendered"
require_relative "view/renderer"
require_relative "view/scope_builder"
require_relative "view/standalone_view"
module Hanami
# A standalone, template-based view rendering system that offers everything
@ -213,298 +215,23 @@ module Hanami
# @!endgroup
# @api private
def self.inherited(klass)
include StandaloneView
def self.inherited(subclass)
super
exposures.each do |name, exposure|
klass.exposures.import(name, exposure)
# If inheriting directly from Hanami::View within an Hanami app, configure
# the view for the application
if subclass.superclass == View && (provider = application_provider(subclass))
subclass.include ApplicationView.new(provider)
end
end
# @!group Exposures
# @!macro [new] exposure_options
# @param options [Hash] the exposure's options
# @option options [Boolean] :layout expose this value to the layout (defaults to false)
# @option options [Boolean] :decorate decorate this value in a matching Part (defaults to
# true)
# @option options [Symbol, Class] :as an alternative name or class to use when finding a
# matching Part
# @overload expose(name, **options, &block)
# Define a value to be passed to the template. The return value of the
# block will be decorated by a matching Part and passed to the template.
#
# The block will be evaluated with the view instance as its `self`. The
# block's parameters will determine what it is given:
#
# - To receive other exposure values, provide positional parameters
# matching the exposure names. These exposures will already by decorated
# by their Parts.
# - To receive the view's input arguments (whatever is passed to
# `View#call`), provide matching keyword parameters. You can provide
# default values for these parameters to make the corresponding input
# keys optional
# - To receive the Context object, provide a `context:` keyword parameter
# - To receive the view's input arguments in their entirety, provide a
# keywords splat parameter (i.e. `**input`)
#
# @example Accessing input arguments
# expose :article do |slug:|
# article_repo.find_by_slug(slug)
# end
#
# @example Accessing other exposures
# expose :articles do
# article_repo.listing
# end
#
# expose :featured_articles do |articles|
# articles.select(&:featured?)
# end
#
# @param name [Symbol] name for the exposure
# @macro exposure_options
#
# @overload expose(name, **options)
# Define a value to be passed to the template, provided by an instance
# method matching the name. The method's return value will be decorated by
# a matching Part and passed to the template.
#
# The method's parameters will determine what it is given:
#
# - To receive other exposure values, provide positional parameters
# matching the exposure names. These exposures will already by decorated
# by their Parts.
# - To receive the view's input arguments (whatever is passed to
# `View#call`), provide matching keyword parameters. You can provide
# default values for these parameters to make the corresponding input
# keys optional
# - To receive the Context object, provide a `context:` keyword parameter
# - To receive the view's input arguments in their entirey, provide a
# keywords splat parameter (i.e. `**input`)
#
# @example Accessing input arguments
# expose :article
#
# def article(slug:)
# article_repo.find_by_slug(slug)
# end
#
# @example Accessing other exposures
# expose :articles
# expose :featured_articles
#
# def articles
# article_repo.listing
# end
#
# def featured_articles
# articles.select(&:featured?)
# end
#
# @param name [Symbol] name for the exposure
# @macro exposure_options
#
# @overload expose(name, **options)
# Define a single value to pass through from the input data (when there is
# no instance method matching the `name`). This value will be decorated by
# a matching Part and passed to the template.
#
# @param name [Symbol] name for the exposure
# @macro exposure_options
# @option options [Boolean] :default a default value to provide if there is no matching
# input data
#
# @overload expose(*names, **options)
# Define multiple values to pass through from the input data (when there
# is no instance methods matching their names). These values will be
# decorated by matching Parts and passed through to the template.
#
# The provided options will be applied to all the exposures.
#
# @param names [Symbol] names for the exposures
# @macro exposure_options
# @option options [Boolean] :default a default value to provide if there is no matching
# input data
#
# @see https://dry-rb.org/gems/dry-view/exposures/
#
# @api public
def self.expose(*names, **options, &block)
if names.length == 1
exposures.add(names.first, block, **options)
else
names.each do |name|
exposures.add(name, **options)
end
def self.application_provider(subclass)
if Hanami.respond_to?(:application?) && Hanami.application?
Hanami.application.component_provider(subclass)
end
end
# @api public
def self.private_expose(*names, **options, &block)
expose(*names, **options, private: true, &block)
end
# Returns the defined exposures. These are unbound, since bound exposures
# are only created when initializing a View instance.
#
# @return [Exposures]
# @api private
def self.exposures
@exposures ||= Exposures.new
end
# @!endgroup
# @!group Render environment
# Returns a render environment for the view and the given options. This
# environment isn't chdir'ed into any particular directory.
#
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
# @param context [Context] context object to use (defaults to the `default_context` setting)
#
# @see View.template_env render environment for the view's template
# @see View.layout_env render environment for the view's layout
#
# @return [RenderEnvironment]
# @api public
def self.render_env(format: config.default_format, context: config.default_context)
RenderEnvironment.prepare(renderer(format), config, context)
end
# @overload template_env(format: config.default_format, context: config.default_context)
# Returns a render environment for the view and the given options,
# chdir'ed into the view's template directory. This is the environment
# used when rendering the template, and is useful to to fetch
# independently when unit testing Parts and Scopes.
#
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
# @param context [Context] context object to use (defaults to the `default_context` setting)
#
# @return [RenderEnvironment]
# @api public
def self.template_env(**args)
render_env(**args).chdir(config.template)
end
# @overload layout_env(format: config.default_format, context: config.default_context)
# Returns a render environment for the view and the given options,
# chdir'ed into the view's layout directory. This is the environment used
# when rendering the view's layout.
#
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
# @param context [Context] context object to use (defaults to the `default_context` setting)
#
# @return [RenderEnvironment] @api public
def self.layout_env(**args)
render_env(**args).chdir(layout_path)
end
# Returns renderer for the view and provided format
#
# @api private
def self.renderer(format)
fetch_or_store(:renderer, config, format) {
Renderer.new(
config.paths,
format: format,
engine_mapping: config.renderer_engine_mapping,
**config.renderer_options
)
}
end
# @api private
def self.layout_path
File.join(*[config.layouts_dir, config.layout].compact)
end
# @!endgroup
# The view's bound exposures
#
# @return [Exposures]
# @api private
attr_reader :exposures
# Returns an instance of the view. This binds the defined exposures to the
# view instance.
#
# Subclasses can define their own `#initialize` to accept injected
# dependencies, but must call `super()` to ensure the standard view
# initialization can proceed.
#
# @api public
def initialize
@exposures = self.class.exposures.bind(self)
end
# The view's configuration
#
# @api private
def config
self.class.config
end
# Render the view
#
# @param format [Symbol] template format to use
# @param context [Context] context object to use
# @param input input data for preparing exposure values
#
# @return [Rendered] rendered view object
# @api public
def call(format: config.default_format, context: config.default_context, **input)
ensure_config
env = self.class.render_env(format: format, context: context)
template_env = self.class.template_env(format: format, context: context)
locals = locals(template_env, input)
output = env.template(config.template, template_env.scope(config.scope, locals))
if layout?
layout_env = self.class.layout_env(format: format, context: context)
output = env.template(
self.class.layout_path,
layout_env.scope(config.scope, layout_locals(locals))
) { output }
end
Rendered.new(output: output, locals: locals)
end
private
# @api private
def ensure_config
raise UndefinedConfigError, :paths unless Array(config.paths).any?
raise UndefinedConfigError, :template unless config.template
end
# @api private
def locals(render_env, input)
exposures.(context: render_env.context, **input) do |value, exposure|
if exposure.decorate? && value
render_env.part(exposure.name, value, **exposure.options)
else
value
end
end
end
# @api private
def layout_locals(locals)
locals.each_with_object({}) do |(key, value), layout_locals|
layout_locals[key] = value if exposures[key].for_layout?
end
end
# @api private
def layout?
!!config.layout # rubocop:disable Style/DoubleNegation
end
private_class_method :application_provider
end
end

View File

@ -0,0 +1,46 @@
module Hanami
class View
class ApplicationView < Module
InheritedHook = Class.new(Module)
attr_reader :provider
attr_reader :application
attr_reader :inherited_hook
def initialize(provider)
@provider = provider
@application = provider.respond_to?(:application) ? provider.application : Hanami.application
@inherited_hook = InheritedHook.new
define_inherited_hook
end
def included(view_class)
view_class.config.paths = [provider.root.join(application.config.views.templates_path).to_s]
view_class.config.layouts_dir = application.config.views.layouts_dir
view_class.config.layout = application.config.views.default_layout
view_class.extend inherited_hook
end
private
def define_inherited_hook
template_name = method(:template_name)
inherited_hook.send :define_method, :inherited do |subclass|
super(subclass)
subclass.config.template = template_name.(subclass)
end
end
def template_name(view_class)
provider
.inflector
.underscore(view_class.name)
.sub(/^#{provider.namespace_path}\//, "")
.sub(/^#{application.config.views.base_path}\//, "")
end
end
end
end

View File

@ -0,0 +1,311 @@
module Hanami
class View
module StandaloneView
def self.included(klass)
klass.extend ClassMethods
klass.include InstanceMethods
end
module ClassMethods
# @api private
def inherited(klass)
super
exposures.each do |name, exposure|
klass.exposures.import(name, exposure)
end
end
# @!group Exposures
# @!macro [new] exposure_options
# @param options [Hash] the exposure's options
# @option options [Boolean] :layout expose this value to the layout (defaults to false)
# @option options [Boolean] :decorate decorate this value in a matching Part (defaults to
# true)
# @option options [Symbol, Class] :as an alternative name or class to use when finding a
# matching Part
# @overload expose(name, **options, &block)
# Define a value to be passed to the template. The return value of the
# block will be decorated by a matching Part and passed to the template.
#
# The block will be evaluated with the view instance as its `self`. The
# block's parameters will determine what it is given:
#
# - To receive other exposure values, provide positional parameters
# matching the exposure names. These exposures will already by decorated
# by their Parts.
# - To receive the view's input arguments (whatever is passed to
# `View#call`), provide matching keyword parameters. You can provide
# default values for these parameters to make the corresponding input
# keys optional
# - To receive the Context object, provide a `context:` keyword parameter
# - To receive the view's input arguments in their entirety, provide a
# keywords splat parameter (i.e. `**input`)
#
# @example Accessing input arguments
# expose :article do |slug:|
# article_repo.find_by_slug(slug)
# end
#
# @example Accessing other exposures
# expose :articles do
# article_repo.listing
# end
#
# expose :featured_articles do |articles|
# articles.select(&:featured?)
# end
#
# @param name [Symbol] name for the exposure
# @macro exposure_options
#
# @overload expose(name, **options)
# Define a value to be passed to the template, provided by an instance
# method matching the name. The method's return value will be decorated by
# a matching Part and passed to the template.
#
# The method's parameters will determine what it is given:
#
# - To receive other exposure values, provide positional parameters
# matching the exposure names. These exposures will already by decorated
# by their Parts.
# - To receive the view's input arguments (whatever is passed to
# `View#call`), provide matching keyword parameters. You can provide
# default values for these parameters to make the corresponding input
# keys optional
# - To receive the Context object, provide a `context:` keyword parameter
# - To receive the view's input arguments in their entirey, provide a
# keywords splat parameter (i.e. `**input`)
#
# @example Accessing input arguments
# expose :article
#
# def article(slug:)
# article_repo.find_by_slug(slug)
# end
#
# @example Accessing other exposures
# expose :articles
# expose :featured_articles
#
# def articles
# article_repo.listing
# end
#
# def featured_articles
# articles.select(&:featured?)
# end
#
# @param name [Symbol] name for the exposure
# @macro exposure_options
#
# @overload expose(name, **options)
# Define a single value to pass through from the input data (when there is
# no instance method matching the `name`). This value will be decorated by
# a matching Part and passed to the template.
#
# @param name [Symbol] name for the exposure
# @macro exposure_options
# @option options [Boolean] :default a default value to provide if there is no matching
# input data
#
# @overload expose(*names, **options)
# Define multiple values to pass through from the input data (when there
# is no instance methods matching their names). These values will be
# decorated by matching Parts and passed through to the template.
#
# The provided options will be applied to all the exposures.
#
# @param names [Symbol] names for the exposures
# @macro exposure_options
# @option options [Boolean] :default a default value to provide if there is no matching
# input data
#
# @see https://dry-rb.org/gems/dry-view/exposures/
#
# @api public
def expose(*names, **options, &block)
if names.length == 1
exposures.add(names.first, block, **options)
else
names.each do |name|
exposures.add(name, **options)
end
end
end
# @api public
def private_expose(*names, **options, &block)
expose(*names, **options, private: true, &block)
end
# Returns the defined exposures. These are unbound, since bound exposures
# are only created when initializing a View instance.
#
# @return [Exposures]
# @api private
def exposures
@exposures ||= Exposures.new
end
# @!endgroup
# @!group Render environment
# Returns a render environment for the view and the given options. This
# environment isn't chdir'ed into any particular directory.
#
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
# @param context [Context] context object to use (defaults to the `default_context` setting)
#
# @see View.template_env render environment for the view's template
# @see View.layout_env render environment for the view's layout
#
# @return [RenderEnvironment]
# @api public
def render_env(format: config.default_format, context: config.default_context)
RenderEnvironment.prepare(renderer(format), config, context)
end
# @overload template_env(format: config.default_format, context: config.default_context)
# Returns a render environment for the view and the given options,
# chdir'ed into the view's template directory. This is the environment
# used when rendering the template, and is useful to to fetch
# independently when unit testing Parts and Scopes.
#
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
# @param context [Context] context object to use (defaults to the `default_context` setting)
#
# @return [RenderEnvironment]
# @api public
def template_env(**args)
render_env(**args).chdir(config.template)
end
# @overload layout_env(format: config.default_format, context: config.default_context)
# Returns a render environment for the view and the given options,
# chdir'ed into the view's layout directory. This is the environment used
# when rendering the view's layout.
#
# @param format [Symbol] template format to use (defaults to the `default_format` setting)
# @param context [Context] context object to use (defaults to the `default_context` setting)
#
# @return [RenderEnvironment] @api public
def layout_env(**args)
render_env(**args).chdir(layout_path)
end
# Returns renderer for the view and provided format
#
# @api private
def renderer(format)
fetch_or_store(:renderer, config, format) {
Renderer.new(
config.paths,
format: format,
engine_mapping: config.renderer_engine_mapping,
**config.renderer_options
)
}
end
# @api private
def layout_path
File.join(*[config.layouts_dir, config.layout].compact)
end
# @!endgroup
end
module InstanceMethods
# Returns an instance of the view. This binds the defined exposures to the
# view instance.
#
# Subclasses can define their own `#initialize` to accept injected
# dependencies, but must call `super()` to ensure the standard view
# initialization can proceed.
#
# @api public
def initialize
@exposures = self.class.exposures.bind(self)
end
# The view's configuration
#
# @api private
def config
self.class.config
end
# The view's bound exposures
#
# @return [Exposures]
# @api private
def exposures
@exposures
end
# Render the view
#
# @param format [Symbol] template format to use
# @param context [Context] context object to use
# @param input input data for preparing exposure values
#
# @return [Rendered] rendered view object
# @api public
def call(format: config.default_format, context: config.default_context, **input)
ensure_config
env = self.class.render_env(format: format, context: context)
template_env = self.class.template_env(format: format, context: context)
locals = locals(template_env, input)
output = env.template(config.template, template_env.scope(config.scope, locals))
if layout?
layout_env = self.class.layout_env(format: format, context: context)
output = env.template(
self.class.layout_path,
layout_env.scope(config.scope, layout_locals(locals))
) { output }
end
Rendered.new(output: output, locals: locals)
end
private
# @api private
def ensure_config
raise UndefinedConfigError, :paths unless Array(config.paths).any?
raise UndefinedConfigError, :template unless config.template
end
# @api private
def locals(render_env, input)
exposures.(context: render_env.context, **input) do |value, exposure|
if exposure.decorate? && value
render_env.part(exposure.name, value, **exposure.options)
else
value
end
end
end
# @api private
def layout_locals(locals)
locals.each_with_object({}) do |(key, value), layout_locals|
layout_locals[key] = value if exposures[key].for_layout?
end
end
# @api private
def layout?
!!config.layout # rubocop:disable Style/DoubleNegation
end
end
end
end
end

View File

@ -0,0 +1,172 @@
# frozen_string_literal: true
require "hanami"
require "hanami/view"
RSpec.describe "Application views" do
context "Outside Hanami app" do
subject(:view_class) { Class.new(Hanami::View) }
before do
allow(Hanami).to receive(:respond_to?).with(:application?) { nil }
end
it "is not an application view" do
expect(view_class.ancestors).not_to include(a_kind_of(Hanami::View::ApplicationView))
end
it "does not configure the view" do
expect(view_class.config.paths).to eq []
end
end
context "Inside Hanami app", :application_integration do
before do
module TestApp
class Application < Hanami::Application
config.root = "/path/to/app"
config.views.base_path = "views"
config.views.templates_path = "templates"
config.views.layouts_dir = "test_app_layouts"
config.views.default_layout = "testing"
end
end
end
context "Base view defined inside slice" do
before do
module Main
end
Hanami.application.register_slice :main, namespace: Main, root: "/path/to/app/slices/main"
Hanami.init
end
let!(:base_view_class) {
module Main
class View < Hanami::View
end
end
Main::View
}
describe "base view class" do
subject(:view_class) { base_view_class }
it "is an application view" do
expect(view_class.ancestors).to include(a_kind_of(Hanami::View::ApplicationView))
end
it "applies configuration from application" do
config = view_class.config
aggregate_failures do
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/slices/main/templates"]
expect(config.layouts_dir).to eq "test_app_layouts"
expect(config.layout).to eq "testing"
end
end
it "does not configure the template" do
expect(view_class.config.template).to be_nil
end
end
describe "subclass of base view class" do
subject(:view_class) {
module Main
module Views
module Articles
class Index < Main::View
end
end
end
end
Main::Views::Articles::Index
}
it "inherits the application-specific configuration from the base class" do
config = view_class.config
aggregate_failures do
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/slices/main/templates"]
expect(config.layouts_dir).to eq "test_app_layouts"
expect(config.layout).to eq "testing"
end
end
it "configures the template name based on the view's class name, relative to the slice and configured views base_path" do
expect(view_class.config.template).to eq "articles/index"
end
end
end
context "Base view defined directly inside application" do
before do
Hanami.init
end
let!(:base_view_class) {
module TestApp
class View < Hanami::View
end
end
TestApp::View
}
describe "base view class" do
subject(:view_class) { base_view_class }
it "is an application view" do
expect(view_class.ancestors).to include(a_kind_of(Hanami::View::ApplicationView))
end
it "applies configuration from application" do
config = view_class.config
aggregate_failures do
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/templates"]
expect(config.layouts_dir).to eq "test_app_layouts"
expect(config.layout).to eq "testing"
end
end
it "does not configure the template" do
expect(view_class.config.template).to be_nil
end
end
describe "subclass of base view class" do
subject(:view_class) {
module TestApp
module Views
module Articles
class Index < TestApp::View
end
end
end
end
TestApp::Views::Articles::Index
}
it "inherits the application-specific configuration from the base class" do
config = view_class.config
aggregate_failures do
expect(config.paths.map { |path| path.dir.to_s }).to eq ["/path/to/app/templates"]
expect(config.layouts_dir).to eq "test_app_layouts"
expect(config.layout).to eq "testing"
end
end
it "configures the template name based on the view's class name, relative to the slice and configured views base_path" do
expect(view_class.config.template).to eq "articles/index"
end
end
end
end
end

View File

@ -11,12 +11,16 @@ FIXTURES_PATH = SPEC_ROOT.join("fixtures")
require "slim"
require "hanami/view"
module Test
def self.remove_constants
module TestNamespace
def remove_constants
constants.each(&method(:remove_const))
end
end
module Test
extend TestNamespace
end
RSpec.configure do |config|
config.disable_monkey_patching!
@ -48,3 +52,5 @@ RSpec::Matchers.define :part_including do |data|
}
}
end
require_relative "support/application_integration"

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
require "hanami/devtools/integration/files"
require "hanami/devtools/integration/with_tmp_directory"
RSpec.shared_context "Application integration" do
let(:application_modules) { %i[TestApp Main] }
end
RSpec.configure do |config|
config.include RSpec::Support::Files, :application_integration
config.include RSpec::Support::WithTmpDirectory, :application_integration
config.include_context "Application integration", :application_integration
config.before :each, :application_integration do
@load_paths = $LOAD_PATH.dup
application_modules.each do |app_module|
Object.const_set(app_module, Module.new { |m| m.extend(TestNamespace) })
end
end
config.after :each, :application_integration do
$LOAD_PATH.replace(@load_paths)
$LOADED_FEATURES.delete_if do |feature_path|
feature_path =~ %r{hanami/(setup|init|boot)}
end
application_modules.each do |app_module|
Object.const_get(app_module).remove_constants
Object.send :remove_const, app_module
end
%i[@_application @_app].each do |ivar|
Hanami.remove_instance_variable(ivar) if Hanami.instance_variable_defined?(ivar)
end
end
end