At the moment, the Hanami View gem is in a prerelease state but I’ve been making heavy use of this gem so wanted to share notes in case this information is helpful to you or, at a minimum, prep you for the upcoming 2.1.0 release. This article assume you have familiarity with Hanami but, if you get lost, you can refer to the Hanami Guides to get up to speed.
History
The Hanami View gem is an evolution of work originally started with the Dry View gem. I’ll be referencing Dry View since some of the documentation still applies for Hanami View.
You want to use Hanami View instead of Dry View since the latter has been eclipsed by the former because the Dry View gem hasn’t been actively worked on for some time. In fact, the last release is a couple years old now.
Setup
You might find the Hanamismith gem useful since it automates generating a Hanami application for you with Hanami View included despite being in a prerelease state. For example, Hanamismith configures your Gemfile
to point to Hanami View main
branch:
gem "hanami-view", github: "hanami/view", branch: "main"
Once Hanami 2.1.0 is released, Hanamismith will be updated to use the 2.1.0 version of all related gems. I’ll be referring to Hanamismith throughout this article since this gem gives you a professional setup for local use.
Quick Start
A quick way to get started is to use Hanamismith to build a Hanami application for you:
gem install hanamismith
hanamismith build --name demo
The above provides a great entry point for learning the basics of Hanami View by starting from the outside and working deeper into the view layer. We’ll start with the routes (the only file not shown above):
# demo/config/routes.rb
module Demo
class Routes < Hanami::Routes
slice(:home, at: "/") { root to: "show" }
end
end
Notice that any request made to /
directs traffic to the show
action of our home slice. If we look at the Show
action, we see:
# demo/slices/home/actions/show.rb
module Home
module Actions
class Show < Home::Action
end
end
end
By default, an action will immediately render the associated view of the same name as shown here:
# demo/slices/home/views/show.rb
module Home
module Views
class Show < Home::View
expose :ruby_version, default: RUBY_VERSION
expose :hanami_version, default: Hanami::VERSION
end
end
end
You’ll notice the Show
view exposes data for rendering within the template of the same name which is shown here but truncated for brevity:
<!-- demo/slices/home/templates/show.html.erb -->
<footer class="footer">
<ul class="group">
<li>Ruby <%= ruby_version %></li>
<li>Hanami <%= hanami_version %></li>
</ul>
</footer>
When we launch the demo application and view the home page you can see the route, action, view, and template render the following page:
That’s quite nice for minimal code and, look, the Ruby and Hanami version information that was exposed via our Show
view can be seen in the page footer!
The rest of this article will delve into the full Hanami View feature set so, without further ado, let’s dive in!
Views
Views are your top level domain objects which provide a complete standalone rendering system that can be composed of several components for greater separation of concerns:
-
Configuration
-
Contexts
-
Parts
-
Helpers
-
Templates and Partials
-
Scopes
We’ll dive into each of the above but, first, I want to demonstrate the power of Hanami View when used in isolation. To begin, each view must inherit from Hanami::View
and have an associated template. Here’s a standalone example — a plain old Ruby object leveraging the Command Pattern — where a default label is injected and exposed for rendering within a template:
#! /usr/bin/env ruby
# frozen_string_literal: true
# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.
require "bundler/inline"
gemfile true do
source "https://rubygems.org"
gem "hanami-view", github: "hanami/view", branch: "main"
end
require "pathname"
Pathname("show.html.erb").write("<h1><%= label %></h1>")
module Demo
module Views
class Show < Hanami::View
config.paths = Pathname.pwd
config.template = "show"
def initialize(label: "Demo", **)
@label = label
super(**)
end
expose(:label) { label }
private
attr_reader :label
end
end
end
puts Demo::Views::Show.new.call
# <h1>Demo</h1>
When unpacking the above, you’ll notice I provide a minimal implementation which:
-
Uses a
show.html.erb
template that renders the exposedlabel
provided by the view. -
Uses a
Show
view which is configured to use the current working directory to look for the associatedshow.html.erb
template. -
Uses a
label
dependency which defaults to"Demo"
and is exposed for rendering within theshow.html.erb
template.
When the view is initialized and called, we see <h1>Demo</h1>
is rendered. This makes views extremely versatile, simple to test in isolation, and great for use outside of a Hanami application. 🚀
Exposures
Exposures allow the passing of values — either via injected dependencies or inputs upon construction — from the view to the templates. Exposures are meant for exposing data only, not behavior. For example, routes can be exposed as follows:
class Index < Hanami::View
include Deps[:routes]
expose(:tasks_path) { routes.path :tasks }
end
Exposures can have defaults:
class Index < Hanami::View
include Deps[:routes]
expose :tasks_path, default: routes.path(:tasks)
end
Defaults can be taken a step further by defining keyword parameters:
# Index pagination that defaults to the first page with a 20 record limit.
class Index < Hanami::View
include Deps[repository: "repositories.task"]
expose(:tasks) { |page: 1, limit: 20| repository.paginate page, limit: }
end
# Show a task by ID.
class Show < Hanami::View
include Deps[repository: "repositories.task"]
expose(:task) { |id:| repository.find id }
end
Exposures can be used within your actions so, given the above, you can supply custom data as keyword arguments to your view when rendered by the action:
class Show < Hanami::Action
def handle request, response
# Implicit, default behavior:
# response.render view
# ...but you can do this instead:
# response[:tasks_path] = "/my/tasks/path"
# ...or this:
# response.render view, tasks_path: "/my/tasks/path"
# The latter two examples are equivalent.
end
end
An exposure can depend on another by making the dependent exposure a positional argument:
class Show < Hanami::View
include Deps["repositories.books", "repositories.authors"]
expose(:book) { |id:| books.find id }
expose(:author) { |book| authors.find book.author_id }
end
Exposures can be private which is handy when you need building blocks — not needed by the template — which are great for use in complex exposures:
class Show < Hanami::View
include Deps[repository: "repositories.task"]
expose(:user_name) { |user, id:| user(id:).name }
private_expose(:user) { |id:| repository.find id }
end
By default, exposures are only available to the template but can be made available to the layout as well by using layout: true
:
class Index < Hanami::View
expose :users, default: [], layout: true
end
Exposures are decorated by parts but this can be disabled, via decorate: false
, since this allows you to use a primitive which has no extra behavior:
class Show < Hanami::View
expose :label, default: "Demo", decorate: false
end
Debugging
Views are convenient to use outside of HTTP requests/responses which simplifies debugging and testing. For example — since every view is a registered dependency — you can access a view instance via the application container as follows:
Home::Slice["views.show"].call
The above would render the following output (truncated for brevity):
<html>
<body>
<h1>Demo</h1>
</body>
</html>
You can take this a step further. In this case, I inspect the view’s locals:
Home::Slice["views.show"].call.locals
# {
# ruby_version: #<Home::Views::Part name=:ruby_version value="3.2.2">,
# hanami_version: #<Home::Views::Part name=:hanami_version value="2.1.0.beta1">
# }
Testing
RSpec.describe Home::Views::Show do
subject(:view) { described_class.new }
describe "#call" do
it "renders heading" do
expect(view.call.to_s).to include("<h1>Demo</h1>")
end
end
end
Configuration
Views are highly customizable — most of which are preconfigured for you when used within a Hanami application — but you can use a custom configuration instead of the defaults. Here’s an example of what you can customize:
class Show < Hanami::View
# Defines the context to be used within views, parts, scopes, templates, and partials.
config.default_context = Alternative.new
# Defines a custom layout.
config.layout = "alternative"
# Defines a custom builder.
config.part_builder = Alternative
# Defines a custom namespace.
config.part_namespace = Alternative
# Defines custom paths.
config.paths = Hanami.app.root.join "alternative/templates"
# Defines custom template.
config.template = "alternative"
end
For more on the above, I’ll refer you to the Dry View Configuration documentation.
Use of a custom configuration emphasizes my earlier example where I show how you can use Hanami View outside of a Hanami application. This is so powerful that I’m using Hanami View to rebuild my Milestoner gem for building release notes. Even better, this works extremely well with an XDG configuration which I’m leveraging to great effect in my Milestoner work.
I do want to note that, at the moment, use of config.paths
has a set and forget behavior because you can’t append paths to it without extra effort. To do this, you need to create a Hanami::View::Path
object which can be appended to your path. Like so:
config.paths.append Hanami::View::Path[Pathname(__dir__).join("templates").expand_path]
The above is necessary because only view paths are allowed at the moment. I point this out because knowing how to append to an existing path — especially when using a slice — allows you to provide fallback behavior that might not have custom templates, parts, etc.
Contexts
Contexts are meant for passing common data between views, parts, scopes, helpers, and templates. For example: current user or asset paths. Each context must inherit from Hanami::View::Context
and doesn’t need to be auto-registered because Hanami will look for the Context
class instead. Each context has access to additional data such as request
, csrf_token
, and much more. Here’s a simple example with no customization:
# demo/views/context.rb
# auto_register: false
module Demo
module Views
class Context < Hanami::View::Context
end
end
end
Contexts are designed to be injected with dependencies. Example:
class DemoContext < Hanami::View::Context
def initialize(title:, description:, **)
@title = title
@description = description
super(**)
end
end
Contexts can be decorated with parts:
class DemoContext < Hanami::View::Context
decorate :navigation
attr_reader :navigation
def initialize(navigation:, **)
@navigation = navigation
super(**)
end
end
You can pass the same options to decorate
as you do with exposures, for example:
class DemoContext < Hanami::View::Context
decorate :navigation, as: :menu
end
Finally, contexts can be passed in when calling the view:
view.call context:
Parts
Parts are the presentation layer and should be familiar territory for those familiar with the presenter/decorator pattern. By default, parts need to be a sub-directory within the views directory (i.e. views/parts
) and will be auto-loaded for you.
You can define a root part via views/part.rb
which inherits from your slice. This allows you to customize your part for the slice much like how view.rb
and action.rb
work at the slice root.
Using the Hemo application, for example, here is a task part which provide additional information when rendered by the view:
module Tasks
module Views
module Parts
class Task < Hanami::View::Part
def assignee = user.name
def checked = ("checked" if completed_at)
def css_class = completed_at ? "task task-completed" : "task"
end
end
end
end
The corresponding spec is:
# frozen_string_literal: true
require "hanami_helper"
RSpec.describe Tasks::Views::Parts::Task do
subject(:part) { described_class.new value: task }
let(:task) { Test::Factory.structs[:task, id: 1, user:] }
let(:user) { Test::Factory.structs[:user, name: "Jane Doe"] }
describe "#assignee" do
it "answers user" do
expect(part.assignee).to eq("Jane Doe")
end
end
describe "#checked" do
it "answers checked when completed" do
allow(task).to receive(:completed_at).and_return(Time.now.utc)
expect(part.checked).to eq("checked")
end
it "answers nil when not completed" do
expect(part.checked).to be(nil)
end
end
describe "#css_class" do
it "answers completed when completed" do
allow(task).to receive(:completed_at).and_return(Time.now.utc)
expect(part.css_class).to eq("task task-completed")
end
it "answers default class when not completed" do
expect(part.css_class).to eq("task")
end
end
end
Other than a tiny bit of setup where I use a user
and task
in-memory ROM factory, I then pass the task
record as a value
to the part and that’s it. Simple! ⚠️ That said, ensure you only use method names that don’t override the default value
method provided for you so you can access the original object injected into your part.
⚠️ You can’t use helpers in parts without a monkey patch. To resolve, use:
class Demo < Hanami::View::Helpers::TagHelper
include Hanami::View::Helpers::TagHelper
private
# TODO: Remove once fixed in Hanami View.
def tag_builder_inflector = Dry::Inflector.new
end
Despite pointing out the above, monkey patches or — more specifically: overrides — are one of many Ruby Antipatterns to avoid. Additionally — because parts are the presentation layer — you should avoid mixing template logic within your parts. Instead, keep your parts solely focused on rendering the data so they can be used with multiple templates as desired. Reusability maximization always yields excellent dividends.
Scopes
Scopes are an evolution of template systems, like ERB, which accept a string template and a binding context. Scopes provide a formalized way of customizing your binding context when the template renders and determines which methods are available for use within a template. A few notes:
-
By default, scopes need to be a sub-directory of views (i.e.
views/scopes
) to be auto-loaded for you. -
Scopes can be namespaced (i.e.
config.scope_namespace = View::Scopes::Demo
) by defining the namespace and then adding scopes to that namespace (i.e.views/scopes/demo/example.rb
). -
Only one namespace can be associated with a view at a time since multiple namespaces are not supported. In order to share functionality, you have to subclass your scopes which carries the same caveats as with standard inheritance so keep your behavior well encapsulated.
-
When you are inside a template,
self
is an instance of a scope. -
Scopes have access to global helpers.
When used outside of Hanami, the default folder structure is not auto-detected so you must be explicit:
class Show < Demo::View
# Equates to `views/scopes`.
config.scope_namespace = Demo::Views::Scopes
end
Here’s an example of an implementation and corresponding spec:
Implementation
# frozen_string_literal: true
require "refinements/arrays"
module Main
module Views
module Scopes
# Encapsulates the rendering of a task description input with possible error.
class Description < Hanami::View::Scope
using Refinements::Arrays
def value = content
def message = (error[:description].to_sentence if error.key? :description)
end
end
end
end
Spec
# frozen_string_literal: true
require "hanami_helper"
RSpec.describe Main::Views::Scopes::Description do
subject(:scope) { described_class.new locals:, rendering: view.new.rendering }
let(:locals) { {content: "Test", error: {description: ["Danger!"]}} }
let :view do
Class.new Hanami::View do
config.paths = SPEC_ROOT
config.template = "n/a"
end
end
describe "#content" do
it "answers content" do
expect(scope.content).to eq("Test")
end
end
describe "#message" do
it "answers error message when error description exists" do
expect(scope.message).to eq("Danger!")
end
it "answers nil when error description doesn't exist" do
locals[:error] = {}
expect(scope.message).to be(nil)
end
end
end
Helpers
Helpers are where you define all of your custom tags for use within your views, parts, scopes, templates, and partials. Example:
module Demo
module Views
module Helpers
def warning
tag.span class: :warning do
# Implicit yielding allows you to captures content from the block for use in the template.
yield
end
end
def error(message) = tag.span message, class: :errr
end
end
end
Helpers are available everywhere: views, parts, scopes, templates, partials, etc. That said, including them within your views, parts, scopes, etc is not a good idea because of this bug. In truth, this is more of a feature than a bug because mixing helpers within your views and parts prevents them from being reused in different formats (i.e. html, rss, json, etc.) — as emphasized earlier — and you don’t want to miss out on that flexibility.
By default, any slices/<name>/views/helper.rb
will be automatically included for use. Example:
module Tasks
module Views
module Helpers
end
end
end
To add additional helpers, you could use this structure within your slice:
slices/<slice>/views/helper.rb
slices/<slice>/views/helpers/one.rb
slices/<slice>/views/helpers/two.rb
For each new helper module you add, you’ll need to manually include them as you would any Ruby module. There is nothing special about helpers, they are normal Ruby modules so, once included, ensure you don’t have method name conflicts with other modules.
⚠️ Helpers have access to the routes
method so ensure you don’t override this.
The following is is not a helper but I’m mentioning it here because it acts like one and allows you to define content in a template for use in another template later. This is powerful when combined with your layout which allows your template to reach up into the layout and customize as necessary. This behavior is very similar to behavior found in other frameworks. Example:
# slices/home/templates/layouts/app.html.erb
<%= content_for :title %>
# slices/home/templates/show.html.erb
<% content_for :title, "Demo" %>
Layouts
Layouts provide a global format for all templates to be embedded within and are enabled by default. In situations where you don’t need a layout, you can pass layout: false
when rendering the view. For example, here’s a show action which renders a response where the layout is disabled for the view:
class Show < Home::Action
def handle(*, response) = response.render view, layout: false
end
At the moment, layouts can only be defined per slice. This is good for separation of concerns but means there isn’t a great way to have a shared theme or common set of functionality that could be inherited and overwritten by each slice. A workaround is to do this:
# demo/slices/home/view.rb
module Home
class View < Demo::View
config.paths = [
# Provides global application fallbacks.
Hanami.app.root.join("app/templates"),
# Use slice specific templates when found, otherwise use application fallbacks.
Pathname(__dir__).join("templates").expand_path
]
end
end
As mentioned earlier, you can’t append to config.paths
unless you use a Hanami::View::Path
object.
Templates and Partials
Templates allow you to consume data exposed via your view for rendering via your template engine (i.e. ERB). They can be broken down further into partials for greater reusability. A template can render a partial in multiple ways. The following demonstrates usage:
# Preferred. Uses `task` as the partial object.
<% tasks.each do |task| %>
<%= task.render :task %>
<% end %>
# Renames `task` as `next_action` for use in the partial.
<% tasks.each do |task| %>
<%= task.render :task, as: :next_action %>
<% end %>
# Passes `label` as additional data to the partial.
<% tasks.each do |task| %>
<%= task.render :task, label: "Test" %>
<% end %>
Escaping
You can escape multiple ways:
<%== demo %>
<%== demo.html_safe %>
<%= raw demo %>
None of the above are recommended because of security vulnerabilities and general Ruby Antipatterns. Instead, you should use the Sanitize to ensure all output is properly sanitized.
Conclusion
I hope you’ve enjoyed this look at the capabilities of the Hanami View gem. If you’d like additional examples to further your learning, I’d recommend the following:
-
Hemo: A Hanami/htmx demonstration application which was initially generated using the Hanamismith gem.
-
Milestoner: A release notes and deployment automation gem that is currently getting a major overhaul to leverage the Hanami View gem in a standalone capacity. I’m looking forward to officially releasing this work in the next major version but am currently dependent upon the Hanami 2.1.0 release.
-
Rocky Mountain Ruby: The upcoming conference in Boulder, Colorado where I’m one of the main speakers who will be presenting on some of what you’ve seen here.